diff --git a/README.md b/README.md index c39225d..31a8780 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,11 @@ read or write failures return a sanitized HTTP 503 before Gitea is contacted. Terminate TLS at the trusted reverse proxy: session cookies are deliberately `Secure`, `HttpOnly`, `SameSite=Strict`, and scoped to the deployment subpath. +Every response also defines the browser execution boundary with a Content Security +Policy that allows scripts only from the dashboard origin, denies framing and +plugins, and blocks unused browser capabilities. Keep these response headers when +proxying; do not add inline scripts or broaden `script-src`. The dashboard bootstrap +and stylesheet are same-origin static assets included in the offline PWA shell. Use **Sign out & clear this device** on shared devices; it clears Stackchain's offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without removing unrelated forge preferences. Rotate either dashboard secret by replacing diff --git a/frontend/dashboard.css b/frontend/dashboard.css new file mode 100644 index 0000000..675bf52 --- /dev/null +++ b/frontend/dashboard.css @@ -0,0 +1,286 @@ +:root { color-scheme: dark; --bg:#050c15; --panel:#0b1526; --line:#1b2d45; --text:#e5e7eb; --accent:#60a5fa; } +* { box-sizing: border-box; } +html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text); font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; overflow-x: hidden; } +#bg { position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: .35; } +header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex; gap:16px; align-items:center; justify-content:space-between; background: linear-gradient(180deg, rgba(11,21,38,.95), rgba(11,21,38,.55), transparent); backdrop-filter: blur(4px); border-bottom: 1px solid #1b2d45; } +.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; } +button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; } +#sign-out-all { min-height:44px; } +button:hover { filter: brightness(1.15); } +.panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); } +.panel > summary { cursor: pointer; list-style-position: inside; } +.panel > summary h2 { display: inline-block; margin-left: 4px; } +.panel[open] > summary { margin-bottom: 8px; } +aside, section { position: relative; z-index: 1; } +main { position: relative; z-index: 1; display: grid; grid-template-columns: 300px 1fr 340px; gap: 14px; padding: 14px; } +@media (max-width: 1100px) { main { grid-template-columns: 1fr; } } +.sidebar { display:flex; flex-direction:column; gap: 12px; } +.stack { display:flex; flex-direction:column; gap:10px; } +h2 { font-size: 13px; margin: 8px 0; text-transform: uppercase; letter-spacing: .08em; color: #94a3b8; } +a { color: #60a5fa; text-decoration: none; } +.muted { color: #94a3b8; } +.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; } +.kv .label { color: #94a3b8; } +.kv .value { color: #e5e7eb; } +.suggestion { padding: 10px; border-radius: 12px; border: 1px solid #1b2d45; background: #0f1d33; margin-bottom: 8px; animation: fadein .35s ease; } +.suggestion.high { border-color: #fca5a5; background: #2a1216; } +.suggestion.medium { border-color: #fcd34d; background: #2a2012; } +.suggestion.low { border-color: #d1fae5; background: #122a1e; } +.row { display: flex; gap: 10px; flex-wrap: wrap; align-items:center; } +.pill { display:inline-block; padding: 2px 8px; border-radius: 999px; background:#0f2237; color:#9ab3d4; border:1px solid #1f3a5f; font-size: 12px; } +canvas { display: block; width: 100%; height: 260px; background: linear-gradient(180deg,#0a1525,#060c16); border: 1px solid #1f3a5f; border-radius: 10px; } +pre { margin: 0; padding: 8px; background: #0b1220; color: #dbe7f2; border-radius: 8px; overflow: auto; max-height: 220px; font-size: 12px; border:1px solid #1f3a5f } +input[type=\"text\"], textarea { width: 100%; padding: 8px; border-radius: 8px; border: 1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; } +textarea { resize: vertical; min-height: 120px; } +#cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(900px, 94vw); background: rgba(11,21,38,.96); border: 1px solid #2a496e; border-radius: 12px; box-shadow: 0 20px 70px rgba(0,0,0,.55); padding: 10px; z-index: 30; display: none; backdrop-filter: blur(12px); } +#cmd-palette.open { display: block; } +.cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; } +.cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; } +.cmd-meta { color:#93a4b8; font-size:12px; text-align:right; } +.cmd-status { padding:10px; color:#93a4b8; font-size:13px; } +.cmd-group { padding:8px 10px 3px; color:#60a5fa; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; } +#whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(5,12,21,.55); display: none; align-items: center; justify-content: center; z-index: 40; backdrop-filter: blur(6px); } +#whiteboard-modal.open, #markdown-modal.open { display: flex; } +.modal { background: #0b1526; border: 1px solid #2a496e; border-radius: 14px; padding: 14px; width: min(1100px, 94vw); max-height: 92vh; overflow: auto; } +.modal-actions { display: flex; gap: 10px; justify-content: space-between; align-items: center; margin-bottom: 8px; } +.badge { font-size: 11px; padding: 4px 8px; border-radius: 999px; background:#0f2237; color:#9ab3d4; border:1px solid #1f3a5f; } +.small { font-size: 12px; color: #94a3b8; } +.status { display:inline-flex; gap:6px; align-items:center; } +.dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 8px #22c55e; } +.offline-status { position:relative; z-index:19; padding:10px 16px; border-bottom:1px solid #f59e0b; background:#35210b; color:#fde68a; font-size:13px; text-align:center; } +.offline-status[hidden] { display:none; } +.offline-work-controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding-top:2px; } +.offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; } +.offline-work-controls input { width:20px; height:20px; } +.offline-work-controls button { min-height:44px; } +.install-app-card { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding:10px; border:1px solid #31577f; border-radius:12px; background:#10233a; } +.install-app-card[hidden] { display:none; } +.install-app-card p { margin:0; flex:1 1 240px; } +.install-app-card button { min-height:44px; } +.widget { border: 1px solid #1b2d45; border-radius: 12px; padding: 10px; background: linear-gradient(180deg,#0f1d33,#0b1526); } +.widget h3 { margin: 4px 0 8px; font-size: 13px; color: #7aa1c9; } +.event { padding: 8px 0; border-bottom: 1px solid #1b2d45; } +.event:last-child { border-bottom: 0; } +.my-work { grid-column: 1 / -1; } +.my-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; } +.work-filters { display:flex; gap:8px; flex-wrap:wrap; } +.work-filter { min-height: 44px; } +.work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; } +.milestone-lane { display:flex; align-items:center; gap:8px; min-width:min(100%,260px); } +.work-milestone-filter { min-width:180px; flex:1; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); } +.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; } +.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; } +.draft-preview { color:var(--muted); overflow-wrap:anywhere; } +.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; } +.draft-actions button { min-height:44px; width:100%; } +.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); } +.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; } +.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; } +.my-work-card:hover { border-color:var(--accent); } +.my-work-card-title { display:block; margin:5px 0; font-weight:650; } +.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; } +.later-actions button { min-height:44px; width:100%; } +.today-actions button { min-height:44px; width:100%; } +.detail-defer { grid-column:1/-1; max-width:100%; } +.detail-defer summary, .detail-defer button { min-height:44px; display:flex; align-items:center; justify-content:center; } +.detail-defer summary { cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; } +.detail-defer-options { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; margin-top:8px; } +.review-sheet-actions { position:sticky; bottom:0; z-index:3; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; } +.mark-update-read { min-height:44px; width:100%; } +.read-update { min-height:44px; width:100%; display:flex; align-items:center; justify-content:center; } +.load-more-notifications { min-height:44px; width:100%; margin-top:10px; } +.load-more-notifications[hidden] { display:none; } +.load-more-work { min-height:44px; width:100%; margin-top:10px; } +.load-more-work[hidden] { display:none; } +.retry-work-route { min-height:44px; width:100%; margin-top:10px; } +.retry-work-route[hidden] { display:none; } +.my-work-bulk { position:sticky; bottom:0; z-index:4; margin:10px -4px -12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; } +.my-work-bulk button { min-height:44px; width:100%; } +.my-work[data-stale="true"] { border-color:#fcd34d; } +.review-sheet { position:fixed; inset:0; z-index:50; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); } +.review-sheet.open { display:flex; } +.review-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; } +.review-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; } +.review-sheet-body { white-space:pre-wrap; overflow-wrap:anywhere; } +.review-file, .review-history { padding:8px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; } +.review-file-toggle { min-height:44px; width:100%; display:flex; align-items:flex-start; justify-content:space-between; gap:8px; text-align:left; } +.review-file-toggle strong { overflow-wrap:anywhere; } +.review-file.reviewed { opacity:.72; } +.review-file.reviewed .review-file-toggle { border-color:#22c55e; } +.review-mark { min-height:44px; width:100%; margin-top:8px; } +.review-mark[aria-pressed="true"] { border-color:#22c55e; background:#123c2a; } +.review-note { min-height:88px; margin-top:6px; } +.review-feedback { display:grid; gap:8px; } +.review-feedback label { display:grid; gap:6px; } +.review-feedback select { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; } +.review-handoff { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; } +.review-handoff button { min-height:44px; } +.review-handoff-link { min-height:44px; display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:8px; color:#bfdbfe; font-weight:700; text-decoration:none; } +.review-handoff-link[hidden] { display:none; } +.review-merge-continuation { position:sticky; bottom:0; z-index:5; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); } +#continue-review-to-merge { min-height:44px; width:100%; } +#continue-review-to-merge[hidden] { display:none; } +.review-copy-fallback { min-height:140px; } +.review-progress-actions { position:sticky; bottom:0; z-index:2; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:8px -4px 0; padding:10px 4px; background:rgba(11,21,38,.96); border-top:1px solid #2a496e; } +.review-progress-actions button { min-height:44px; max-width:100%; } +.review-diff { overflow-x:auto; max-width:100%; margin-top:8px; white-space:pre; } +.review-diff-line { display:block; min-width:max-content; } +.review-inline-target { min-height:44px; width:100%; padding:8px; border:0; border-radius:0; text-align:left; font:inherit; white-space:pre; } +.review-inline-target.has-draft { box-shadow:inset 4px 0 #fbbf24; } +.review-inline-composer { position:sticky; bottom:0; z-index:4; display:grid; gap:8px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #60a5fa; border-radius:10px; background:#0b1526; } +.review-inline-composer[hidden] { display:none; } +.review-inline-composer textarea { min-height:96px; width:100%; } +.review-inline-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; } +.review-inline-actions button { min-height:44px; } +.review-diff-line.hunk { color:#93c5fd; } +.review-diff-line.added { color:#86efac; background:rgba(34,197,94,.09); } +.review-diff-line.removed { color:#fca5a5; background:rgba(239,68,68,.09); } +.review-diff-note, .review-diff-empty { display:block; padding:8px; color:#fcd34d; white-space:normal; } +.review-action { min-height:44px; } +.review-retry { min-height:44px; margin-top:10px; } +.update-sheet { position:fixed; inset:0; z-index:55; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); } +.update-sheet.open { display:flex; } +.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; } +.update-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; } +.update-sheet-header button { min-height:44px; } +.update-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; } +.update-reply { display:grid; gap:8px; margin-top:16px; } +.update-reply textarea { width:100%; min-height:112px; resize:vertical; } +.update-reply button { min-height:44px; width:100%; } +.update-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; } +.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; } +.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; } +.update-retry { min-height:44px; width:100%; margin-top:10px; } +.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); } +.issue-sheet.open { display:flex; } +.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; } +.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; } +.issue-sheet-header button { min-height:44px; } +.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; } +.issue-planning { max-width:100%; margin-top:16px; border:1px solid #2a496e; border-radius:12px; padding:0 12px 12px; overflow-x:hidden; } +.issue-planning > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; } +.issue-planning-retry { min-height:44px; width:100%; } +#edit-issue-content { min-height:44px; width:100%; } +.issue-edit-form { display:grid; gap:8px; max-width:100%; margin:12px 0; } +.issue-edit-form label { display:grid; gap:6px; min-width:0; } +.issue-edit-form input, .issue-edit-form textarea { box-sizing:border-box; width:100%; max-width:100%; } +.issue-edit-form textarea { min-height:132px; resize:vertical; } +.issue-edit-form input, .issue-edit-form textarea, .issue-edit-form button { min-height:44px; } +.issue-edit-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; } +.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; } +.issue-comment-composer { display:grid; gap:8px; margin-top:16px; } +.issue-comment-composer button { min-height:44px; width:100%; } +.issue-label-editor { max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; } +.issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(180px,100%),1fr)); gap:8px; max-width:100%; } +.issue-label-option { min-height:44px; max-width:100%; display:flex; align-items:center; gap:10px; padding:8px; border:1px solid #2a496e; border-radius:10px; overflow-wrap:anywhere; } +.issue-label-option input { width:20px; height:20px; flex:0 0 auto; } +.issue-label-editor button { min-height:44px; width:100%; margin-top:10px; } +.issue-due-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; } +.issue-due-editor input { box-sizing:border-box; width:100%; max-width:100%; } +.issue-due-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; } +.issue-due-editor input, .issue-due-editor button { min-height:44px; } +.issue-milestone-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; } +.issue-milestone-editor select { width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); } +.work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px; } +.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; } +.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; } +.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; } +.issue-handoff { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; } +.issue-handoff > div { display:grid; gap:8px; margin-top:10px; } +.issue-handoff select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); } +.issue-handoff select, .issue-handoff button { min-height:44px; } +.issue-retry { min-height:44px; width:100%; margin-top:10px; } +.new-issue { min-height:44px; } +.find-work-action { min-height:44px; } +.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; } +.start-work-session { min-height:44px; } +.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; } +.work-session-nav[hidden] { display:none; } +.work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; } +.work-session-nav button { min-height:44px; width:100%; } +.find-work-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); } +.find-work-sheet.open { display:flex; } +.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; } +.find-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; } +.find-work-header button, .find-work-card button, .find-work-card a, .find-work-more { min-height:44px; } +.find-work-list { display:grid; gap:10px; } +.find-work-card { display:grid; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; } +.find-work-card button { width:100%; font-weight:700; } +.find-work-detail { min-width:0; display:grid; gap:10px; padding:10px; border-radius:10px; background:#0b1526; } +.find-work-description { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; } +.find-work-detail a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; } +.search-preview { position:fixed; inset:0; z-index:60; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); } +.search-preview.open { display:flex; } +.search-preview-panel { box-sizing:border-box; width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; overflow-wrap:anywhere; } +.search-preview-header { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; } +.search-preview-header button, .search-preview-actions button, .search-preview-actions a { min-height:44px; } +.search-preview-body { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; } +.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; } +.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; } +@media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } } +.create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); } +.create-issue-sheet.open { display:flex; } +.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; } +.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; } +.create-issue-header button, .create-issue-actions button { min-height:44px; } +.create-issue-form { display:grid; gap:12px; } +.create-issue-form label { display:grid; gap:6px; } +.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; } +.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; } +.create-issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; } +.create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; } +.create-issue-label-option input { width:20px; height:20px; margin:0; } +.create-issue-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; } +.shared-content-conflict { display:grid; gap:8px; padding:12px; border:1px solid #8b5cf6; border-radius:10px; background:#16142b; } +.shared-content-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; } +.shared-content-actions button { min-height:44px; } +.pull-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); } +.pull-sheet.open { display:flex; } +.pull-sheet-panel { width:min(560px,100%); height:100dvh; overflow:auto; padding:18px; padding-bottom:calc(90px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; } +.pull-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; } +.pull-sheet-header button, .pull-sheet-actions button, .pull-sheet-actions a, .pull-comment-composer button { min-height:44px; } +.pull-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; } +.pull-file, .pull-comment-card { margin:8px 0; padding:10px; border:1px solid #203a5c; border-radius:10px; } +.conversation-more { min-height:44px; width:100%; margin:8px 0; } +.pull-file-toggle, .pull-review-file { min-height:44px; width:100%; } +.pull-file-toggle { display:flex; justify-content:space-between; align-items:center; gap:8px; text-align:left; } +.pull-review-file { margin-top:8px; } +.pull-diff { overflow-x:auto; margin:8px 0; padding:10px; background:#07111f; border-radius:8px; font-size:12px; } +.pull-diff-line { display:block; width:max-content; min-width:100%; } +.pull-diff-line.added { color:#86efac; background:#123422; } +.pull-diff-line.removed { color:#fca5a5; background:#3b161b; } +.pull-diff-line.hunk, .pull-diff-note { color:#93c5fd; } +.pull-diff-empty { margin:8px 0; padding:10px; border:1px dashed #4e6b8a; border-radius:8px; } +.pull-review-tools { display:flex; align-items:center; justify-content:space-between; gap:8px; margin:8px 0; } +.pull-review-tools button { min-height:44px; } +.pull-review { margin-top:14px; overflow:hidden; border:1px solid #2a496e; border-radius:10px; padding:0 10px 10px; } +.pull-review summary { min-height:44px; display:flex; align-items:center; cursor:pointer; } +.pull-review summary h2 { margin:0; font-size:16px; } +.pull-review > #merge-pull { min-height:44px; width:100%; margin-top:10px; } +.pull-comment-composer textarea { width:100%; min-height:110px; resize:vertical; } +.pull-sheet-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; } +.pull-sheet-actions a { display:grid; place-items:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; } +.pull-retry { min-height:44px; width:100%; margin-top:10px; } +.mobile-task-dock { display:none; } +.mobile-task-dock[hidden] { display:none; } +.mobile-task-action { min-width:0; min-height:44px; padding:6px 2px; border:0; border-radius:8px; background:transparent; display:grid; place-items:center; gap:2px; font-size:12px; } +.mobile-task-action[aria-current="page"] { color:#bfdbfe; background:#17365a; outline:1px solid #31577f; } +.mobile-task-count { min-width:18px; min-height:18px; padding:1px 5px; border-radius:999px; background:#31577f; font-size:11px; line-height:16px; } +@media (max-width: 600px) { + body { padding-bottom:calc(66px + env(safe-area-inset-bottom)); } + header { align-items:flex-start; } + .my-work { margin:0; } + .my-work-list { grid-template-columns:1fr; } + .work-filters { width:100%; } + .work-filter { flex:1 1 calc(50% - 8px); } + .review-sheet-panel { width:100%; border-left:0; padding:14px; } + .update-sheet-panel { width:100%; border-left:0; padding:14px; } + .issue-sheet-panel { width:100%; border-left:0; padding:14px; } + .search-preview-panel { width:100%; border-left:0; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); overflow-x:hidden; } + .create-issue-panel { width:100%; border-left:0; padding:14px; } + .pull-sheet-panel { width:100%; border-left:0; padding:14px; } + .mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); } +} +.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,') center/contain no-repeat; display:inline-block; } +.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; } +@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } } diff --git a/frontend/dashboard.js b/frontend/dashboard.js new file mode 100644 index 0000000..e277611 --- /dev/null +++ b/frontend/dashboard.js @@ -0,0 +1,2963 @@ +(function(){ + const qs = (s, el=document) => el.querySelector(s); + const fmt = (d) => new Date(d).toLocaleString(); + const PANEL_STATE_KEY = "stackchain.panel-state.v1"; + const panels = Array.from(document.querySelectorAll('details[data-panel-key]')); + let savedState = {}; + try { + savedState = JSON.parse(localStorage.getItem(PANEL_STATE_KEY) || '{}'); + } catch (e) { + savedState = {}; + } + panels.forEach((panel) => { + if (Object.prototype.hasOwnProperty.call(savedState, panel.dataset.panelKey)) { + panel.open = savedState[panel.dataset.panelKey]; + } + panel.addEventListener('toggle', () => { + const state = Object.fromEntries(panels.map((item) => [item.dataset.panelKey, item.open])); + try { + localStorage.setItem(PANEL_STATE_KEY, JSON.stringify(state)); + } catch (e) { + console.warn('Could not persist panel state', e); + } + }); + }); + const mobileTaskButtons = Object.fromEntries( + Array.from(document.querySelectorAll('[data-mobile-task]')).map(button => [button.dataset.mobileTask, button]) + ); + const mobileTaskOverlays = Array.from(document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal')); + function openMobileWork() { + const counts = countMyWork(activeMyWork); + const filter = todayMyWork.length ? 'today' : (counts.attention ? 'attention' : 'all'); + qs('[data-work-filter="' + filter + '"]').click(); + qs('#my-work').scrollIntoView({block:'start'}); + qs('#my-work').focus(); + } + const mobileTaskDock = createMobileTaskDock({ + nav: qs('#mobile-task-dock'), + buttons: mobileTaskButtons, + attentionBadge: qs('#mobile-attention-count'), + overlays: mobileTaskOverlays, + actions: { + work: openMobileWork, + find: () => qs('#find-work').click(), + new: () => qs('#new-issue').click(), + search: () => qs('#open-palette').click(), + drafts: () => qs('[data-work-filter="draft"]').click(), + }, + observe(callback, overlays) { + const observer = new MutationObserver(callback); + overlays.forEach(overlay => observer.observe(overlay, {attributes:true, attributeFilter:['class']})); + return observer; + }, + }); + mobileTaskDock.start(); + const sourceDraftCount = qs('[data-work-count="draft"]'); + const draftCount = qs('#mobile-draft-count'); + function syncMobileDraftCount() { + draftCount.textContent = sourceDraftCount.textContent; + draftCount.setAttribute('aria-label', sourceDraftCount.textContent + ' drafts'); + } + new MutationObserver(syncMobileDraftCount).observe(sourceDraftCount, {childList:true, characterData:true, subtree:true}); + syncMobileDraftCount(); + let liveMode = true; + let offlineWorkMode = false; + const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1'; + const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1'; + let selectedWorkFilter = 'all'; + let selectedWorkMilestone = 'all'; + try { + const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY); + if (['all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) selectedWorkFilter = savedFilter; + const savedMilestone = sessionStorage.getItem(WORK_MILESTONE_KEY); + if (savedMilestone) selectedWorkMilestone = savedMilestone; + } catch (e) { + console.warn('Could not restore My Work filter', e); + } + let lastMyWork = []; + let lastDrafts = []; + let lastNotifications = []; + let lastContextSnapshot = null; + let notificationPagination = { page: 1, total: 0, has_more: false }; + let workPagination = {}; + let hasContextSnapshot = false; + let selectedReview = null; + let reviewTrigger = null; + let selectedUpdate = null; + let updateTrigger = null; + let selectedIssue = null; + let selectedIssueDetail = null; + let issueConversation = null; + let issueTrigger = null; + let selectedPull = null; + let pullTrigger = null; + let selectedPullDetail = null; + let pullConversation = null; + let pullReviewState = null; + let creatingIssue = false; + let findingWork = false; + let availablePagination = { page: 1, total: 0, has_more: false }; + let progress = null; + let draft = null; + let reviewFiles = []; + let selectedReviewHead = ''; + let activeInlineTarget = null; + let bulkConfirmationPending = false; + let bulkMarkPending = false; + let reviewHandoffPending = false; + let editingOutboxId = null; + let confirmedOwnerLogin = ''; + let planningOwnerLogin = ''; + let activeFlushLogin = ''; + let activeMyWork = []; + let laterMyWork = []; + let todayMyWork = []; + const todayWork = createTodayWork({ + storage: localStorage, + getLogin: () => planningOwnerLogin, + }); + const laterWork = createLaterWork({ + storage: localStorage, + getLogin: () => planningOwnerLogin, + onWake: () => { + qs('#my-work-action-status').textContent = 'Deferred work is ready again.'; + refreshMyWorkView(); + }, + }); + document.addEventListener('visibilitychange', () => { + if (!document.hidden) refreshMyWorkView(); + }); + + async function fetchReviewJson(url, options) { + const response = await fetch(url, options); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const error = new Error(payload.error || payload.detail || 'Review request failed.'); + error.status = response.status; + throw error; + } + return payload; + } + + async function api(url) { + const response = await fetch(url, { headers: { Accept: 'application/json' } }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const error = new Error(payload.error || payload.detail || 'Shared work request failed.'); + error.unavailable = response.status === 404; + throw error; + } + return payload; + } + const reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage }); + const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage }); + const planningLoader = createIssueSheet.createPlanningLoader({ + loadLabels: item => issueController.loadLabels(item), + loadMilestones: item => issueController.loadMilestones(item), + }); + const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage }); + let backgroundIssueSync = null; + if ('indexedDB' in window) { + const backgroundIssueStore = createIssueSyncStore(); + backgroundIssueSync = createBackgroundIssueSync({ + store: backgroundIssueStore, fetchJson: fetchReviewJson, + }); + backgroundIssueSync.requestSync = async () => { + if (!('serviceWorker' in navigator)) throw new Error('Background Sync unavailable'); + const registration = await navigator.serviceWorker.ready; + if (!registration.sync) throw new Error('Background Sync unavailable'); + await registration.sync.register('stackchain-issue-outbox-v1'); + }; + } + const outboxCoordinator = createOutboxCoordinator({ storage: localStorage }); + const issueOutbox = createIssueOutbox({ + storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator, + backgroundSync: backgroundIssueSync, + getOwnerLogin: () => confirmedOwnerLogin, + }); + const authoredOutbox = createAuthoredOutbox({ + storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator, + backgroundSync: backgroundIssueSync, + getOwnerLogin: () => confirmedOwnerLogin, + }); + if (backgroundIssueSync) { + backgroundIssueSync.snapshot().then(records => { + issueOutbox.reconcileBackground(records); + authoredOutbox.reconcileBackground(records); + }).catch(() => { /* The foreground localStorage outboxes remain available. */ }); + } + const shareParams = new URLSearchParams(location.search); + const sharedLaunch = { + title: shareParams.get('title') || '', + text: shareParams.get('text') || '', + url: shareParams.get('url') || '', + }; + let sharedLaunchState = Object.values(sharedLaunch).some(Boolean) ? + issueCapture.stageSharedContent(sharedLaunch) : null; + const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage }); + const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin }); + outboxCoordinator.subscribe(() => refreshMyWorkView()); + const offlineWorkStore = createOfflineWorkStore({ storage: localStorage }); + const findWorkController = createFindWork({ + fetchJson: fetchReviewJson, + onItems: renderAvailableIssues, + onPagination: pagination => { + availablePagination = pagination; + qs('#load-more-available').hidden = !pagination.has_more; + }, + onStatus: message => { qs('#find-work-status').textContent = message; }, + }); + + function setStatus(msg) { qs('#status').textContent = msg || 'Live'; } + function setClock() { qs('#clock').textContent = fmt(new Date()); } + setClock(); setInterval(setClock, 1000); + + async function fetchLiveSnapshot(revisions = {}) { + const params = new URLSearchParams(); + Object.entries(revisions).forEach(([section, revision]) => { + if (Number.isInteger(revision) && revision >= 0) params.set(section + '_revision', revision); + }); + const query = params.toString(); + const res = await fetch('api/v1/live' + (query ? '?' + query : ''), { + headers: { Accept: 'application/json' }, + }); + if (!res.ok) throw new Error('HTTP ' + res.status); + return res.json(); + } + + async function markNotificationRead(notificationId) { + const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/read', { + method: 'PATCH', + headers: { Accept: 'application/json' }, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || 'Mark read failed.'); + return payload; + } + + async function markNotificationsRead(ids) { + const response = await fetch('api/v1/notifications/read', { + method: 'PATCH', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || 'Bulk mark read failed.'); + return payload; + } + + async function fetchNotificationPage(page) { + const response = await fetch('api/v1/notifications?page=' + encodeURIComponent(page), { + headers: { Accept: 'application/json' }, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || 'Loading unread updates failed.'); + return payload; + } + + async function fetchNotificationDetail(notificationId) { + const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId), { + headers: { Accept: 'application/json' }, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || 'Loading the update failed.'); + return payload; + } + + async function fetchNotificationConversation(notificationId, page) { + const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + + '/conversation?page=' + encodeURIComponent(page) + '&limit=20', { + headers: { Accept: 'application/json' }, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || 'Loading older messages failed.'); + return payload; + } + + async function fetchWorkPage(stream, page) { + const response = await fetch('api/v1/work/' + encodeURIComponent(stream) + '?page=' + encodeURIComponent(page), { + headers: { Accept: 'application/json' }, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || 'Loading older work failed.'); + return payload; + } + + async function postNotificationReply(notificationId, body, operationId) { + const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/reply', { + method: 'POST', + headers: { + Accept: 'application/json', 'Content-Type': 'application/json', + 'Idempotency-Key': operationId, + }, + body: JSON.stringify({ body }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || 'Posting the reply failed.'); + return payload; + } + + const notificationAcknowledger = createNotificationAcknowledger({ + markRead: markNotificationRead, + onItems: items => { + lastMyWork = items; + refreshMyWorkView(); + }, + onStatus: message => { qs('#my-work-action-status').textContent = message; }, + }); + const bulkNotificationAcknowledger = createBulkNotificationAcknowledger({ + markRead: markNotificationsRead, + onItems: items => { + lastMyWork = items; + refreshMyWorkView(); + }, + onStatus: message => { qs('#my-work-action-status').textContent = message; }, + }); + const notificationPager = createNotificationPager({ + load: fetchNotificationPage, + onNotifications: items => { + lastNotifications = items; + if (lastContextSnapshot) { + lastContextSnapshot.notifications = lastNotifications; + paintMyWork(lastContextSnapshot); + } + }, + onPagination: pagination => { + notificationPagination = pagination; + const loaded = Math.min(pagination.total, pagination.page * 50); + qs('#notification-page-status').textContent = pagination.total ? + loaded + ' of ' + pagination.total + ' unread updates loaded.' : ''; + qs('#load-more-notifications').hidden = + selectedWorkFilter !== 'update' || !pagination.has_more; + }, + onStatus: message => { qs('#my-work-action-status').textContent = message; }, + }); + const workPager = createWorkPager({ + load: fetchWorkPage, + onItems: (stream, items) => { + if (!lastContextSnapshot) return; + if (stream === 'issue') lastContextSnapshot.issues = items; + else lastContextSnapshot.pull_requests = items; + paintMyWork(lastContextSnapshot); + }, + onPagination: pagination => { + workPagination = pagination; + updateWorkPaginationControls(); + }, + onStatus: message => { qs('#my-work-action-status').textContent = message; }, + }); + const notificationReplier = createNotificationReplier({ + post: postNotificationReply, + storage: localStorage, + authoredOutbox, + onStatus: message => { qs('#update-reply-status').textContent = message; }, + }); + const notificationReader = createNotificationReader({ + load: fetchNotificationDetail, + loadConversation: fetchNotificationConversation, + markRead: markNotificationRead, + onOpen: item => { + selectedUpdate = item; + qs('#update-sheet').classList.add('open'); + qs('#update-sheet-key').textContent = item.key || ''; + qs('#update-sheet-title').textContent = item.title || 'Unread update'; + qs('#update-comments').textContent = ''; + qs('#update-conversation-status').textContent = ''; + qs('#load-older-update-comments').hidden = true; + qs('#update-subject-body').textContent = ''; + qs('#update-subject-type').textContent = item.subject_type || 'Update'; + qs('#update-subject-state').textContent = item.state || ''; + qs('#open-update-gitea').href = item.url || '#'; + qs('#update-reply').value = notificationReplier.loadDraft(item); + qs('#update-reply-status').textContent = ''; + qs('#send-update-reply').disabled = false; + qs('#retry-update-load').hidden = true; + qs('#keep-update-unread').focus(); + }, + onDetail: detail => { + qs('#update-sheet-title').textContent = detail.title || 'Unread update'; + qs('#update-subject-type').textContent = detail.subject_type || 'Update'; + qs('#update-subject-state').textContent = detail.state || ''; + qs('#update-subject-body').textContent = detail.subject_body || 'No subject context was provided.'; + qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#'; + qs('#retry-update-load').hidden = true; + }, + onConversation: renderUpdateConversation, + onItems: items => { + const readId = selectedUpdate?.notification_id; + lastMyWork = items; + if (Number.isInteger(readId)) { + lastNotifications = lastNotifications.filter(item => item.id !== readId); + } + refreshMyWorkView(); + }, + onStatus: message => { + qs('#update-sheet-status').textContent = message; + qs('#retry-update-load').hidden = !message.startsWith('Could not load update.'); + if (message === 'Inbox cleared.') qs('#my-work-action-status').textContent = message; + }, + onClose: () => closeUpdateSheet(false), + }); + + function routedWorkItem(item) { + if (item?.has_update && item.kind === 'update') { + return { ...item, kind:'update' }; + } + return { ...item, kind:item?.is_review ? 'review' : item?.kind }; + } + + function openRoutedWork(item, trigger, options = {}) { + if (!item) return; + if (offlineWorkMode) { + qs('#my-work-action-status').textContent = 'This saved item is read-only. Reconnect to open live details.'; + return; + } + if (item.has_update && item.kind === 'update') updateTrigger = trigger; + else if (item.is_review) reviewTrigger = trigger; + else if (item.kind === 'issue') issueTrigger = trigger; + else if (item.kind === 'pull') pullTrigger = trigger; + workRoute.open(routedWorkItem(item), options); + } + + const workRoute = createWorkRoute.createController({ + location: window.location, + history: window.history, + eventTarget: window, + resolve: route => { + const params = new URLSearchParams({ kind:route.kind }); + if (route.kind === 'update') params.set('notification_id', route.notification_id); + else { + params.set('repository', route.repository); + params.set('number', route.number); + } + return api('api/v1/work-route?' + params.toString()); + }, + onResolving: () => { + qs('#retry-work-route').hidden = true; + qs('#my-work-action-status').textContent = 'Loading shared work item…'; + }, + onOpen: item => { + qs('#retry-work-route').hidden = true; + qs('#my-work-action-status').textContent = ''; + closeOpenWorkSheets(); + if (item.kind === 'update') notificationReader.open(item, lastMyWork); + else if (item.kind === 'review') openReviewSheet(item, reviewTrigger); + else if (item.kind === 'issue') openIssueSheet(item, issueTrigger); + else if (item.kind === 'pull') openPullSheet(item, pullTrigger); + }, + onClose: () => { + if (selectedIssue) closeIssueSheet(false); + if (selectedPull) closePullSheet(false); + if (selectedReview) closeReviewSheet(false); + if (selectedUpdate) closeUpdateSheet(true, false); + }, + onInvalid: () => { + qs('#retry-work-route').hidden = true; + window.history.replaceState(null, '', window.location.pathname + window.location.search); + closeOpenWorkSheets(); + qs('#my-work-action-status').textContent = 'Route unavailable · this item is no longer in My Work.'; + qs('#my-work').scrollIntoView({ block:'start' }); + }, + onError: () => { + qs('#my-work-action-status').textContent = 'Could not load shared work item. The link is preserved; retry when connected.'; + qs('#retry-work-route').hidden = false; + }, + }); + workRoute.start(); + qs('#retry-work-route').addEventListener('click', () => workRoute.sync()); + + function closeOpenWorkSheets() { + ['#issue-sheet', '#pull-sheet', '#review-sheet', '#update-sheet'].forEach(selector => + qs(selector).classList.remove('open') + ); + selectedIssue = null; + selectedIssueDetail = null; + selectedPull = null; + selectedPullDetail = null; + selectedReview = null; + selectedUpdate = null; + } + + function openWorkSessionItem(item) { + openRoutedWork(item, null, { replace:true }); + } + + const workSession = createWorkSession({ + getItems: () => selectedWorkFilter === 'today' ? todayMyWork : activeMyWork, + getFilter: () => selectedWorkFilter === 'today' ? 'all' : selectedWorkFilter, + getMilestone: () => selectedWorkMilestone, + onOpen: openWorkSessionItem, + onProgress: state => { + document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = false; }); + document.querySelectorAll('[data-work-session-progress]').forEach(element => { + element.textContent = 'Item ' + state.index + ' of ' + state.total; + }); + document.querySelectorAll('[data-work-session-previous]').forEach(button => { + button.disabled = !state.can_previous; + }); + document.querySelectorAll('[data-work-session-next]').forEach(button => { + button.textContent = state.can_next ? 'Next work item' : 'Finish session'; + }); + }, + onFinish: () => { + closeOpenWorkSheets(); + document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; }); + qs('#my-work-action-status').textContent = 'Work session complete.'; + qs('#start-work-session').focus(); + }, + }); + + const detailDefer = createDetailDefer({ + laterWork, + session: workSession, + close: () => workRoute.close(), + refresh: refreshMyWorkView, + focus: () => qs('[data-work-filter="' + selectedWorkFilter + '"]')?.focus(), + announce: message => { qs('#my-work-action-status').textContent = message; }, + formatTime: fmt, + }); + document.querySelectorAll('[data-detail-defer-preset]').forEach(button => { + button.addEventListener('click', () => { + const item = selectedUpdate || selectedReview || selectedIssue || selectedPull; + button.closest('.detail-defer').open = false; + detailDefer.defer(item, button.dataset.detailDeferPreset); + }); + }); + document.querySelectorAll('[data-detail-defer-cancel]').forEach(button => { + button.addEventListener('click', () => { + const chooser = button.closest('.detail-defer'); + chooser.open = false; + chooser.querySelector('summary')?.focus(); + }); + }); + + function updatePlanningAvailability() { + document.querySelectorAll('[data-detail-defer-preset]').forEach(button => { + button.disabled = !planningOwnerLogin; + button.toggleAttribute('data-planning-disabled', !planningOwnerLogin); + }); + } + + function renderContextSnapshot(data) { + liveMode = true; + hasContextSnapshot = true; + if (lastContextSnapshot && Object.values(workPagination).some(page => page.page > 1)) { + const merge = (older, latest) => { + const byId = new Map((older || []).map(item => [item.id, item])); + (latest || []).forEach(item => { + const current = byId.get(item.id) || {}; + const reasons = Array.from(new Set( + (current.work_reasons || []).concat(item.work_reasons || []) + )); + byId.set(item.id, { + ...current, ...item, ...(reasons.length ? { work_reasons: reasons } : {}), + }); + }); + return Array.from(byId.values()); + }; + data.issues = merge(lastContextSnapshot.issues, data.issues); + data.pull_requests = merge(lastContextSnapshot.pull_requests, data.pull_requests); + } + lastContextSnapshot = data; + if (data.work_pagination) workPager.reset(data.work_pagination); + if (data.error && lastMyWork.length) markMyWorkStale(); + else paintMyWork(data); + openStagedSharedContent(); + qs('#context').innerHTML = '
User
' + escapeHtml(data.user?.full_name || data.user?.login || '—') + '
' + + '
Repos
' + (data.repos?.length || 0) + '
' + + '
Issues
' + (data.issues?.length || 0) + '
' + + '
PRs
' + (data.pull_requests?.length || 0) + '
'; + qs('#view-hint').textContent = 'Active view: ' + (data.view || 'dashboard'); + + const issuesBox = qs('#issues-content'); + const openIssues = (data.issues || []).filter(i => i.state === 'open').slice(0, 12); + issuesBox.innerHTML = (openIssues.length ? openIssues.map(i => '
#' + i.number + ' ' + escapeHtml(i.title) + '' + + '
' + (i.labels || []).map(l => '' + escapeHtml(String(l)) + '').join(' ') + '
').join('') : '
No open issues.
'); + + const prsBox = qs('#prs-content'); + const openPrs = (data.pull_requests || []).slice(0, 12); + prsBox.innerHTML = (openPrs.length ? openPrs.map(p => '
#' + p.number + ' ' + escapeHtml(p.title) + '' + + '
' + escapeHtml(p.state) + ' by ' + escapeHtml(String(p.user || '')) + '
').join('') : '
No PRs.
'); + + paintDeltas(data.deltas || []); + qs('#layout-hint').innerHTML = '
Active view
' + escapeHtml(data.view || 'dashboard') + '
Deltas
' + (data.deltas||[]).length + '
'; + renderRepoMix(qs('#repo-mix'), data); + setStatus(data.error ? 'Degraded · ' + data.error : 'Live · updated just now'); + setClock(); + } + + function handleContextError(e) { + console.error('context failed', e); + liveMode = false; + activeFlushLogin = ''; + if (!hasContextSnapshot && hydrateOfflineWork('outage')) return; + setStatus(hasContextSnapshot ? 'Update failed · showing last snapshot' : 'Unavailable'); + if (!hasContextSnapshot) { + qs('#context').innerHTML = '
Context unavailable.
'; + qs('#view-hint').textContent = 'Active view unavailable.'; + qs('#issues-content').innerHTML = '
Work items unavailable.
'; + qs('#prs-content').innerHTML = '
Work items unavailable.
'; + paintDeltas([]); + } + markMyWorkStale(); + } + + function paintMyWork(data) { + lastMyWork = buildMyWork(data); + refreshMyWorkView(); + } + + function refreshMyWorkView() { + lastDrafts = draftInbox.list(); + const partitioned = laterWork.partition(lastMyWork, { + pruneMissing: !Object.values(workPagination).some(page => page?.has_more), + }); + activeMyWork = partitioned.active; + laterMyWork = partitioned.later; + todayMyWork = todayWork.reconcile(lastMyWork, { + pruneMissing: !Object.values(workPagination).some(page => page?.has_more), + }); + const counts = countMyWork(activeMyWork); + counts.today = todayMyWork.length; + counts.later = laterMyWork.length; + counts.draft = lastDrafts.length; + Object.entries(counts).forEach(([filter, count]) => { + const element = qs('[data-work-count="' + filter + '"]'); + if (element) element.textContent = count; + }); + mobileTaskDock.updateAttention(counts.attention); + const milestoneSelect = qs('#work-milestone-filter'); + const lanes = milestoneLanes(lastMyWork); + milestoneSelect.innerHTML = '' + + '' + lanes.map(lane => + '' + ).join(''); + milestoneSelect.value = selectedWorkMilestone; + if (milestoneSelect.value !== selectedWorkMilestone) { + const retained = document.createElement('option'); + retained.value = selectedWorkMilestone; + retained.textContent = 'Selected milestone'; + milestoneSelect.append(retained); + milestoneSelect.value = selectedWorkMilestone; + } + qs('#my-work').removeAttribute('data-stale'); + qs('#my-work-status').textContent = lastMyWork.length ? + summarizeMyWork(activeMyWork) + (laterMyWork.length ? ' · ' + laterMyWork.length + ' deferred' : '') : + 'No assigned work, review requests, or unread updates.'; + updateWorkPaginationControls(); + renderMyWork(); + if (workSession.active()) workSession.reconcile(); + } + + function activeWorkStreams() { + if (selectedWorkFilter === 'today') return ['issue', 'pull', 'review']; + if (selectedWorkFilter === 'attention') return ['issue', 'pull', 'review']; + if (selectedWorkFilter === 'issue') return ['issue']; + if (selectedWorkFilter === 'pull') return ['pull']; + if (selectedWorkFilter === 'review') return ['review']; + if (selectedWorkFilter === 'all') return ['issue', 'pull', 'review']; + return []; + } + + function renderDrafts() { + const list = qs('#my-work-list'); + list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => { + const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox'; + const outboxActions = item.quarantined ? + '' + + '' : + item.kind === 'issue-outbox' ? + '' + + '' + + '' : + item.kind === 'authored-outbox' ? + '' + + '' + + '' : + '' + + ''; + const state = isOutbox ? + '' + (item.quarantined ? 'Identity protected' : + (item.status === 'attention' ? 'Needs attention' : 'Queued for sync')) + '' + + (item.ownership ? '
' + escapeHtml(item.ownership) + '
' : '') : ''; + return '
' + + '' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '' + + '' + escapeHtml(item.title) + '' + + '' + escapeHtml(item.preview || 'Unfinished draft') + '' + state + + 'Saved ' + escapeHtml(fmt(item.updated_at)) + '' + + '
' + outboxActions + '
'; + }).join('') : '
No unfinished drafts.
'; + list.querySelectorAll('.draft-resume').forEach(button => { + button.addEventListener('click', () => { + const item = lastDrafts[Number(button.dataset.draftIndex)]; + if (!item) return; + if (item.kind === 'new-issue') openCreateIssueSheet(); + else if (item.route) workRoute.open(item.route); + }); + }); + list.querySelectorAll('.draft-edit').forEach(button => { + button.addEventListener('click', () => { + const item = lastDrafts[Number(button.dataset.draftIndex)]; + const queued = issueOutbox.list().find(candidate => candidate.id === item?.outbox_id); + if (!queued) return; + editingOutboxId = queued.id; + issueCapture.saveDraft(queued); + openCreateIssueSheet(); + qs('#create-issue-status').textContent = 'Edit this queued issue, then send again.'; + }); + }); + list.querySelectorAll('.draft-send').forEach(button => { + button.addEventListener('click', async () => { + const item = lastDrafts[Number(button.dataset.draftIndex)]; + if (!item?.outbox_id) return; + button.disabled = true; + if (item.kind === 'authored-outbox') applyAuthoredOutboxResult(await authoredOutbox.retry(item.outbox_id, activeFlushLogin)); + else applyOutboxResult(await issueOutbox.retry(item.outbox_id, activeFlushLogin)); + }); + }); + list.querySelectorAll('.draft-copy').forEach(button => { + button.addEventListener('click', async () => { + const item = lastDrafts[Number(button.dataset.draftIndex)]; + if (!item?.copy_text) return; + await navigator.clipboard.writeText(item.copy_text); + qs('#my-work-action-status').textContent = 'Queued content copied without sending it.'; + }); + }); + list.querySelectorAll('.draft-discard').forEach(button => { + button.addEventListener('click', () => { + if (!window.confirm('Discard this unfinished draft?')) return; + const item = lastDrafts[Number(button.dataset.draftIndex)]; + if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id); + else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id); + else if (item) draftInbox.discard(item.id); + lastDrafts = draftInbox.list(); + const count = qs('[data-work-count="draft"]'); + if (count) count.textContent = lastDrafts.length; + renderDrafts(); + qs('#my-work-action-status').textContent = 'Draft discarded.'; + }); + }); + } + + function updateWorkPaginationControls() { + const labels = { issue: 'issues', pull: 'pull requests', review: 'review requests' }; + const streams = activeWorkStreams(); + const incomplete = streams.filter(stream => workPagination[stream]?.has_more); + const summaries = streams.flatMap(stream => { + const page = workPagination[stream]; + return page && page.total ? + [Math.min(page.total, page.page * 50) + ' of ' + page.total + ' ' + labels[stream]] : []; + }); + qs('#work-page-status').textContent = summaries.join(' · '); + qs('#load-more-work').hidden = incomplete.length === 0; + qs('#load-more-work').textContent = incomplete.length ? + 'Load older ' + labels[incomplete[0]] : 'Load older work'; + } + + function renderMyWork() { + lastDrafts = draftInbox.list(); + const draftCount = qs('[data-work-count="draft"]'); + if (draftCount) draftCount.textContent = lastDrafts.length; + if (selectedWorkFilter === 'draft') { + renderDrafts(); + qs('#bulk-mark-read-bar').hidden = true; + qs('#load-more-notifications').hidden = true; + return; + } + const visible = selectedWorkFilter === 'today' ? + filterMyWork(todayMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'later' ? + filterMyWork(laterMyWork, 'all', selectedWorkMilestone) : + filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone); + const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more); + qs('#my-work-list').innerHTML = visible.length ? visible.map(item => { + const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind); + const routeItem = routedWorkItem(item); + const routeHref = createWorkRoute.serialize(routeItem); + const contents = + '' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : (item.kind === 'update' ? 'Update' : 'Issue')) + '' + + '' + escapeHtml(item.title) + '' + + '' + escapeHtml(item.reason) + '' + + (item.milestone?.title ? ' ' + escapeHtml(item.milestone.title) + '' : '') + + (item.due_label ? ' ' + escapeHtml(item.due_label) + '' : '') + + (item.has_update ? ' Unread update' : '') + + (item.deferred_until ? 'Deferred until ' + escapeHtml(fmt(item.deferred_until)) + '' : '') + + (item.updated_at ? ' · Updated ' + escapeHtml(fmt(item.updated_at)) + '' : ''); + const markRead = item.has_update && Number.isInteger(item.notification_id) ? + '' : ''; + const readUpdate = item.has_update && Number.isInteger(item.notification_id) ? + 'Read update' : ''; + const planningDisabled = planningOwnerLogin ? '' : ' disabled data-planning-disabled'; + const laterActions = selectedWorkFilter === 'later' ? + '
' : + '
'; + const alreadyToday = todayWork.contains(item); + const todayPosition = todayWork.position(item); + const todayActions = selectedWorkFilter === 'today' ? + '
' : + '
'; + const planningActions = todayActions + laterActions; + if (item.is_review) { + return '
' + contents + '' + readUpdate + markRead + planningActions + '
'; + } + if (item.kind === 'issue') { + return '
' + contents + '' + readUpdate + markRead + planningActions + '
'; + } + if (item.kind === 'pull') { + return '
' + contents + '' + readUpdate + markRead + planningActions + '
'; + } + return '
' + contents + '' + markRead + planningActions + '
'; + }).join('') : '
' + (incomplete ? + 'More work is available. Load the next page.' : + 'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.') + '
'; + document.querySelectorAll('[data-review-index]').forEach(button => { + button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); }); + }); + document.querySelectorAll('[data-issue-index]').forEach(button => { + button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.issueIndex)], button); }); + }); + document.querySelectorAll('[data-pull-index]').forEach(button => { + button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.pullIndex)], button); }); + }); + document.querySelectorAll('[data-update-index]').forEach(button => { + button.addEventListener('click', event => { + event.preventDefault(); + const item = lastMyWork[Number(button.dataset.updateIndex)]; + if (!item) return; + updateTrigger = button; + workRoute.open({ ...item, kind:'update' }); + }); + }); + document.querySelectorAll('[data-notification-id]').forEach(button => { + button.addEventListener('click', async () => { + const notificationId = Number(button.dataset.notificationId); + button.disabled = true; + const acknowledged = await notificationAcknowledger.acknowledge(lastMyWork, notificationId); + if (acknowledged) { + lastNotifications = lastNotifications.filter(item => item.id !== notificationId); + (document.querySelector('[data-notification-id]') || qs('[data-work-filter="update"]'))?.focus(); + } else { + document.querySelector('[data-notification-id="' + notificationId + '"]')?.focus(); + } + }); + }); + document.querySelectorAll('[data-later-preset]').forEach(button => { + button.addEventListener('click', () => { + const item = lastMyWork[Number(button.dataset.workIndex)]; + if (!item) return; + if (!planningOwnerLogin) { + qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.'; + return; + } + const until = laterWork.presetUntil(button.dataset.laterPreset); + const result = laterWork.defer(item, until); + qs('#my-work-action-status').textContent = result === 'deferred' ? + 'Deferred until ' + fmt(until) + '; work stays unread and unchanged in Gitea.' : + (result === 'invalid' ? 'Choose a valid future time.' : 'Could not save Later on this device.'); + if (result !== 'deferred') return; + refreshMyWorkView(); + }); + }); + document.querySelectorAll('[data-later-restore]').forEach(button => { + button.addEventListener('click', () => { + const item = lastMyWork[Number(button.dataset.workIndex)]; + if (!item || !laterWork.restore(item)) return; + qs('#my-work-action-status').textContent = 'Work returned to its priority position.'; + refreshMyWorkView(); + }); + }); + document.querySelectorAll('[data-today-add]').forEach(button => { + button.addEventListener('click', () => { + if (!planningOwnerLogin) { + qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.'; + return; + } + const result = todayWork.add(lastMyWork[Number(button.dataset.workIndex)]); + qs('#my-work-action-status').textContent = result === 'full' ? + 'Today is limited to 5 items. Remove one before adding more.' : + (result === 'added' ? 'Added to Today without changing Gitea.' : + (result === 'exists' ? 'This item is already in Today.' : 'Could not save Today on this device.')); + refreshMyWorkView(); + }); + }); + document.querySelectorAll('[data-today-remove]').forEach(button => { + button.addEventListener('click', () => { + todayWork.remove(lastMyWork[Number(button.dataset.workIndex)]); + qs('#my-work-action-status').textContent = 'Removed from Today without changing Gitea.'; + refreshMyWorkView(); + }); + }); + document.querySelectorAll('[data-today-move]').forEach(button => { + button.addEventListener('click', () => { + todayWork.move(lastMyWork[Number(button.dataset.workIndex)], button.dataset.todayMove); + refreshMyWorkView(); + document.querySelector('[data-today-move="' + button.dataset.todayMove + '"][data-work-index="' + button.dataset.workIndex + '"]')?.focus(); + }); + }); + const allIds = notificationIds(visible); + const ids = allIds.slice(0, Math.min(lastNotifications.length, 50)); + const bulkBar = qs('#bulk-mark-read-bar'); + const bulkButton = qs('#bulk-mark-read'); + bulkBar.hidden = selectedWorkFilter !== 'update' || ids.length === 0; + qs('#load-more-notifications').hidden = + selectedWorkFilter !== 'update' || !notificationPagination.has_more; + bulkButton.disabled = bulkMarkPending; + const bulkLabel = allIds.length > ids.length ? + 'next ' + ids.length + ' of ' + allIds.length + ' loaded updates' : + 'all ' + ids.length + ' updates'; + bulkButton.textContent = bulkConfirmationPending ? + 'Confirm marking ' + bulkLabel + ' read' : + 'Mark ' + bulkLabel + ' read'; + workRoute.setItems(lastMyWork); + } + + function reviewFileElement(filename) { + return Array.from(document.querySelectorAll('.review-file')).find( + element => element.dataset.reviewFilename === filename + ); + } + + function showReviewProgress(snapshot) { + const complete = snapshot.total > 0 && snapshot.reviewedCount === snapshot.total; + qs('#review-progress').textContent = complete ? + 'All files reviewed — finish in Gitea' : + (snapshot.newHead ? 'New commits detected · ' : '') + snapshot.reviewedCount + ' of ' + snapshot.total + ' files reviewed'; + qs('#next-unreviewed-review').disabled = !snapshot.nextFilename; + document.querySelectorAll('.review-mark').forEach(button => { + const reviewed = snapshot.reviewed.includes(button.dataset.reviewFilename); + button.setAttribute('aria-pressed', String(reviewed)); + button.textContent = reviewed ? 'Reviewed' : 'Mark reviewed'; + button.closest('.review-file')?.classList.toggle('reviewed', reviewed); + }); + } + + function openNextUnreviewed(snapshot) { + if (!snapshot?.nextFilename) return; + const file = reviewFileElement(snapshot.nextFilename); + if (!file) return; + const toggle = file.querySelector('.review-file-toggle'); + const panel = toggle && document.getElementById(toggle.getAttribute('aria-controls')); + if (toggle && panel && toggle.getAttribute('aria-expanded') !== 'true') { + createReviewController.toggleDiff(toggle, panel); + } + file.scrollIntoView({ behavior: 'smooth', block: 'center' }); + toggle?.focus(); + } + + function renderIssueComment(comment) { + return '
' + + escapeHtml(comment.author || 'Unknown author') + + (comment.created_at ? ' · ' + escapeHtml(fmt(comment.created_at)) : '') + + '
' + + escapeHtml(comment.body || 'No comment body provided.') + '
'; + } + + function renderIssueConversation(state) { + const comments = state?.comments || []; + qs('#issue-comments').innerHTML = comments.length ? + comments.map(renderIssueComment).join('') : '
No comments yet.
'; + qs('#load-older-issue-comments').hidden = !Number.isInteger(state?.older_page); + qs('#issue-conversation-status').textContent = comments.length ? + comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.'; + } + + function renderUpdateConversation(state) { + const comments = state?.comments || []; + qs('#update-comments').innerHTML = comments.length ? + comments.map(renderIssueComment).join('') : '
No comments yet.
'; + qs('#load-older-update-comments').hidden = !Number.isInteger(state?.older_page); + qs('#update-conversation-status').textContent = comments.length ? + comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.'; + } + + function renderPullConversation(state) { + const comments = state?.comments || []; + qs('#pull-comments').innerHTML = comments.length ? comments.map(comment => + '
' + renderIssueComment(comment) + '
' + ).join('') : '
No comments yet.
'; + qs('#load-older-pull-comments').hidden = !Number.isInteger(state?.older_page); + qs('#pull-conversation-status').textContent = comments.length ? + comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.'; + } + + function renderIssueLabelEditor(item, confirmedNames, labels) { + const list = qs('#issue-label-list'); + const status = qs('#issue-label-status'); + list.textContent = ''; + const draftIds = issueController.loadLabelDraft(item); + const selectedIds = draftIds.length ? new Set(draftIds) : new Set( + labels.filter(label => confirmedNames.includes(label.name)).map(label => Number(label.id)) + ); + list.innerHTML = labels.map(label => + '' + ).join(''); + status.textContent = labels.length ? 'Choose labels, then save.' : 'This repository has no labels.'; + qs('#save-issue-labels').disabled = false; + } + + function selectedEditIssueLabelIds() { + return Array.from(document.querySelectorAll('input[name="issue-label"]:checked')) + .map(input => Number(input.value)).filter(Number.isInteger); + } + + function renderIssueMilestoneEditor(item, confirmedMilestone, milestones) { + const select = qs('#issue-milestone'); + const status = qs('#issue-milestone-status'); + select.innerHTML = ''; + select.innerHTML += milestones.map(milestone => + '' + ).join(''); + const draft = issueController.loadMilestoneDraft(item); + select.value = String(draft ?? confirmedMilestone?.id ?? ''); + select.disabled = false; + qs('#save-issue-milestone').disabled = false; + status.textContent = confirmedMilestone ? + 'Planned for ' + confirmedMilestone.title + '.' : 'No milestone set.'; + } + + async function loadIssuePlanning() { + const item = selectedIssue; + const detail = selectedIssueDetail; + if (!item || !detail) return; + qs('#retry-issue-planning').hidden = true; + qs('#issue-label-status').textContent = 'Loading labels…'; + qs('#issue-milestone-status').textContent = 'Loading milestones…'; + try { + const planning = await planningLoader.open(selectedIssue); + if (selectedIssue !== item) return; + renderIssueLabelEditor(item, detail.labels || [], planning.labels); + renderIssueMilestoneEditor(item, detail.milestone, planning.milestones); + } catch (_error) { + if (selectedIssue !== item) return; + qs('#issue-label-status').textContent = 'Labels could not be loaded.'; + qs('#issue-milestone-status').textContent = 'Milestones could not be loaded.'; + qs('#retry-issue-planning').hidden = false; + } + } + + async function openIssueSheet(item, trigger) { + if (!item) return; + selectedIssue = item; + selectedIssueDetail = null; + issueConversation = null; + issueTrigger = trigger; + qs('#issue-sheet').classList.add('open'); + qs('#issue-sheet-key').textContent = item.key || ''; + qs('#issue-sheet-title').textContent = item.title || 'Assigned issue'; + qs('#issue-sheet-status').textContent = 'Loading issue…'; + qs('#issue-sheet-body').textContent = ''; + qs('#issue-labels').textContent = ''; + qs('#issue-assignees').textContent = ''; + qs('#issue-comments').textContent = ''; + qs('#load-older-issue-comments').hidden = true; + qs('#issue-conversation-status').textContent = 'Loading newest messages…'; + qs('#issue-comment').value = issueController.loadDraft(item); + qs('#issue-comment-status').textContent = ''; + qs('#issue-handoff').open = false; + qs('#issue-handoff-recipient').innerHTML = ''; + qs('#issue-handoff-recipient').disabled = true; + qs('#confirm-issue-handoff').disabled = true; + qs('#load-issue-handoff').disabled = false; + qs('#issue-handoff-status').textContent = 'Load teammates to transfer ownership.'; + qs('#issue-planning').open = false; + qs('#retry-issue-planning').hidden = true; + qs('#issue-label-list').textContent = ''; + qs('#issue-label-status').textContent = 'Expand planning controls to load labels.'; + qs('#save-issue-labels').disabled = true; + qs('#issue-due-date').value = ''; + qs('#issue-due-date').disabled = true; + qs('#save-issue-due-date').disabled = true; + qs('#clear-issue-due-date').disabled = true; + qs('#issue-due-status').textContent = 'Loading due date…'; + qs('#issue-milestone').innerHTML = ''; + qs('#issue-milestone').disabled = true; + qs('#save-issue-milestone').disabled = true; + qs('#issue-milestone-status').textContent = 'Expand planning controls to load milestones.'; + qs('#retry-issue-load').hidden = true; + qs('#open-issue-gitea').href = item.url || '#'; + qs('#send-issue-comment').disabled = false; + qs('#edit-issue-content').disabled = true; + qs('#issue-edit-form').hidden = true; + qs('#issue-edit-status').textContent = ''; + qs('#close-issue').disabled = false; + qs('#close-issue-sheet').focus(); + try { + const detail = await issueController.load(item); + if (selectedIssue !== item) return; + selectedIssueDetail = detail; + issueConversation = issueController.conversation(item, detail.conversation); + qs('#issue-sheet-title').textContent = detail.title || 'Assigned issue'; + qs('#issue-sheet-body').textContent = detail.body || 'No description provided.'; + qs('#issue-labels').innerHTML = (detail.labels || []).map(label => + '' + escapeHtml(label) + '' + ).join(' '); + qs('#issue-assignees').textContent = (detail.assignees || []).length ? + 'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported'; + renderIssueConversation(issueConversation.snapshot()); + qs('#open-issue-gitea').href = detail.url || item.url || '#'; + qs('#issue-sheet-status').textContent = 'Issue ready · ' + (detail.state || 'open'); + qs('#edit-issue-content').disabled = false; + const dueDraft = issueController.loadDueDateDraft(item); + qs('#issue-due-date').value = String(dueDraft || detail.due_date || '').slice(0, 10); + qs('#issue-due-date').disabled = false; + qs('#save-issue-due-date').disabled = false; + qs('#clear-issue-due-date').disabled = !detail.due_date; + qs('#issue-due-status').textContent = detail.due_date ? + 'Due ' + new Date(detail.due_date).toLocaleDateString() : 'No due date set.'; + if (qs('#issue-planning').open) loadIssuePlanning(); + } catch (error) { + if (selectedIssue !== item) return; + qs('#issue-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.'; + qs('#retry-issue-load').hidden = false; + qs('#retry-issue-load').focus(); + } + } + + function closeIssueSheet(navigate = true) { + if (navigate && createWorkRoute.parse(window.location.hash)) { + workRoute.close(); + return; + } + qs('#issue-sheet').classList.remove('open'); + selectedIssue = null; + selectedIssueDetail = null; + issueConversation = null; + if (issueTrigger?.isConnected) issueTrigger.focus(); + } + + function renderPullReview(detail, focusFilename = null) { + pullReviewState = pullController.reviewState(selectedPull, detail); + qs('#pull-review-progress').textContent = pullReviewState.total ? + pullReviewState.reviewed.length + ' of ' + pullReviewState.total + ' files reviewed' : 'No changed files to review'; + qs('#next-unreviewed-pull-file').disabled = pullReviewState.complete; + const eligibility = createPullSheet.mergeEligibility(detail, pullReviewState); + qs('#pull-merge-state').textContent = eligibility.reason; + qs('#merge-pull').disabled = !eligibility.allowed; + qs('#pull-files').innerHTML = (detail.files || []).length ? detail.files.map((file, index) => + createPullSheet.renderFile(file, index, pullReviewState.reviewed.includes(file.filename), escapeHtml) + ).join('') : '
No changed files reported.
'; + qs('#pull-files').querySelectorAll('.pull-file-toggle').forEach(button => { + button.addEventListener('click', () => { + const panel = document.getElementById(button.getAttribute('aria-controls')); + const expanded = button.getAttribute('aria-expanded') === 'true'; + button.setAttribute('aria-expanded', String(!expanded)); + if (panel) panel.hidden = expanded; + }); + }); + qs('#pull-files').querySelectorAll('.pull-review-file').forEach(button => { + button.addEventListener('click', () => { + const filename = button.dataset.pullReviewFile; + pullReviewState = pullController.toggleReviewed(selectedPull, detail, filename); + renderPullReview(detail, filename); + }); + }); + if (focusFilename) { + Array.from(qs('#pull-files').querySelectorAll('.pull-review-file')) + .find(button => button.dataset.pullReviewFile === focusFilename)?.focus(); + } + } + + function focusNextUnreviewedPullFile() { + if (!selectedPullDetail || !pullReviewState) return; + const filename = pullController.nextUnreviewed(selectedPullDetail, pullReviewState); + const article = Array.from(qs('#pull-files').querySelectorAll('.pull-file')) + .find(file => file.dataset.pullFilename === filename); + const toggle = article?.querySelector('.pull-file-toggle'); + const panel = toggle && document.getElementById(toggle.getAttribute('aria-controls')); + if (toggle && panel) { + toggle.setAttribute('aria-expanded', 'true'); + panel.hidden = false; + toggle.scrollIntoView({ block: 'center', behavior: 'smooth' }); + toggle.focus(); + } + } + + async function loadPullReview() { + if (!selectedPull || !selectedPullDetail?.head_sha) return; + const item = selectedPull; + const readDetail = selectedPullDetail; + qs('#pull-review-status').textContent = 'Loading changed files and merge readiness…'; + qs('#pull-review-retry').hidden = true; + qs('#merge-pull').disabled = true; + try { + const review = await pullController.loadReview(item, readDetail.head_sha); + if (selectedPull !== item) return; + selectedPullDetail = { ...readDetail, ...review }; + qs('#pull-ci-state').textContent = 'CI ' + (review.ci_state || 'unknown'); + renderPullReview(selectedPullDetail); + qs('#pull-review-status').textContent = review.head_sha === readDetail.head_sha ? + 'Review data ready for the current head.' : 'New commits detected. Review progress restarted for the latest head.'; + } catch (error) { + if (selectedPull !== item) return; + qs('#pull-review-status').textContent = error.message + ' Reading and replies remain available; retry here.'; + qs('#pull-review-retry').hidden = false; + qs('#pull-merge-state').textContent = 'Review data unavailable'; + qs('#merge-pull').disabled = true; + } + } + + async function openPullSheet(item, trigger) { + if (!item) return; + selectedPull = item; + pullTrigger = trigger; + selectedPullDetail = null; + pullConversation = null; + pullReviewState = null; + qs('#pull-sheet').classList.add('open'); + qs('#pull-sheet-key').textContent = item.key || ''; + qs('#pull-sheet-title').textContent = item.title || 'Assigned pull request'; + qs('#pull-sheet-status').textContent = 'Loading pull request…'; + qs('#pull-sheet-body').textContent = ''; + qs('#pull-files').textContent = ''; + qs('#pull-review').open = false; + qs('#pull-review-status').textContent = 'Expand to load changed files and merge readiness.'; + qs('#pull-review-retry').hidden = true; + qs('#pull-review-progress').textContent = 'Review progress unavailable.'; + qs('#next-unreviewed-pull-file').disabled = true; + qs('#pull-comments').textContent = ''; + qs('#load-older-pull-comments').hidden = true; + qs('#pull-conversation-status').textContent = 'Loading newest messages…'; + qs('#pull-comment').value = pullController.loadDraft(item); + qs('#pull-comment-status').textContent = ''; + qs('#pull-ci-state').textContent = 'CI unknown'; + qs('#pull-merge-state').textContent = 'Review data not loaded'; + qs('#merge-pull').disabled = true; + qs('#retry-pull-load').hidden = true; + qs('#open-pull-gitea').href = item.url || '#'; + qs('#close-pull-sheet').focus(); + try { + const detail = await pullController.load(item); + if (selectedPull !== item) return; + selectedPullDetail = detail; + pullConversation = pullController.conversation(item, detail.conversation); + qs('#pull-sheet-title').textContent = detail.title || 'Assigned pull request'; + qs('#pull-sheet-body').textContent = detail.body || 'No description provided.'; + renderPullConversation(pullConversation.snapshot()); + qs('#open-pull-gitea').href = detail.url || item.url || '#'; + qs('#pull-sheet-status').textContent = 'Pull request ready · by ' + (detail.author || 'unknown author'); + } catch (error) { + if (selectedPull !== item) return; + qs('#pull-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.'; + qs('#retry-pull-load').hidden = false; + qs('#retry-pull-load').focus(); + } + } + + function closePullSheet(navigate = true) { + if (navigate && createWorkRoute.parse(window.location.hash)) { + workRoute.close(); + return; + } + qs('#pull-sheet').classList.remove('open'); + selectedPull = null; + selectedPullDetail = null; + pullConversation = null; + pullReviewState = null; + if (pullTrigger?.isConnected) pullTrigger.focus(); + } + + function selectedIssueLabelIds() { + return Array.from(document.querySelectorAll('input[name="create-issue-label"]:checked')) + .map(input => Number(input.value)).filter(Number.isInteger); + } + + function renderAvailableIssues(items) { + const list = qs('#find-work-list'); + list.innerHTML = items.length ? items.map((item, index) => { + const expanded = findWorkController.isPreviewed(item); + const detailId = 'find-work-detail-' + index; + const detail = '

' + escapeHtml(item.body || 'No description provided.') + '

' + + (item.url ? 'Open in Gitea' : '') + + '
'; + return '
' + escapeHtml(item.repository) + '#' + + Number(item.number) + '
' + escapeHtml(item.title || 'Untitled issue') + '' + + '
' + (item.labels || []).map(label => '' + escapeHtml(label) + '').join(' ') + + '
' + + detail + '
'; + }).join('') : '
No unassigned issues are available on this page.
'; + list.querySelectorAll('[data-preview-index]').forEach(button => { + button.addEventListener('click', () => { + const item = findWorkController.items()[Number(button.dataset.previewIndex)]; + if (!item) return; + const expanded = findWorkController.togglePreview(item); + const detail = document.getElementById(button.getAttribute('aria-controls')); + button.setAttribute('aria-expanded', String(expanded)); + button.textContent = expanded ? 'Hide details' : 'View details'; + if (detail) detail.hidden = !expanded; + }); + }); + list.querySelectorAll('[data-claim-index]').forEach(button => { + button.addEventListener('click', async () => { + const item = findWorkController.items()[Number(button.dataset.claimIndex)]; + if (!item) return; + button.disabled = true; + try { + const confirmed = await findWorkController.claim(item); + lastContextSnapshot = lastContextSnapshot || { user: {}, repos: [], issues: [], pull_requests: [] }; + lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []); + lastMyWork = buildMyWork(lastContextSnapshot); + const claimed = lastMyWork.find(work => + work.kind === 'issue' && work.repository === confirmed.repository && work.number === confirmed.number + ); + closeFindWorkSheet(); + refreshMyWorkView(); + qs('#my-work-action-status').textContent = confirmed.repository + '#' + confirmed.number + ' assigned to you.'; + openRoutedWork(claimed, qs('#find-work')); + } catch (error) { + qs('#find-work-status').textContent = error.message + ' Refresh and retry.'; + button.disabled = false; + button.focus(); + } + }); + }); + } + + async function openFindWorkSheet() { + findingWork = true; + qs('#find-work-sheet').classList.add('open'); + const retainedItems = findWorkController.items(); + if (retainedItems.length) renderAvailableIssues(retainedItems); + else qs('#find-work-list').textContent = ''; + qs('#find-work-status').textContent = retainedItems.length ? + 'Refreshing available issues…' : 'Loading available issues…'; + qs('#load-more-available').hidden = true; + qs('#close-find-work').focus(); + try { + const result = await findWorkController.load(); + if (!result?.stale) { + qs('#find-work-status').textContent = findWorkController.items().length ? + findWorkController.items().length + ' of ' + availablePagination.total + ' available issues loaded.' : + 'No unassigned issues are available.'; + } + } catch (error) { + qs('#find-work-status').textContent = error.message + ' Close and retry.'; + } + } + + function closeFindWorkSheet() { + findingWork = false; + qs('#find-work-sheet').classList.remove('open'); + qs('#find-work').focus(); + } + + function saveIssueCaptureDraft() { + issueCapture.saveDraft({ + repository: qs('#create-issue-repository').value, + title: qs('#create-issue-title').value, + body: qs('#create-issue-body').value, + labelIds: selectedIssueLabelIds(), + milestoneId: Number(qs('#create-issue-milestone').value) || null, + dueDate: qs('#create-issue-due-date').value, + }); + } + + async function loadIssueLabels(repository, selectedIds = []) { + const list = qs('#create-issue-label-list'); + const status = qs('#create-issue-label-status'); + list.innerHTML = ''; + if (!repository) { + status.textContent = 'Choose a repository to load labels.'; + return; + } + status.textContent = 'Loading labels…'; + try { + const labels = await issueCapture.loadLabels(repository); + const selected = new Set(selectedIds.map(Number)); + list.innerHTML = labels.map(label => + '' + ).join(''); + status.textContent = labels.length ? 'Select labels to triage this issue.' : 'This repository has no labels.'; + } catch (error) { + status.textContent = 'Labels could not be loaded. You can still create the issue without labels.'; + } + } + + async function loadIssueMilestones(repository, selectedId = null) { + const select = qs('#create-issue-milestone'); + const status = qs('#create-issue-milestone-status'); + select.innerHTML = ''; + if (!repository) { + status.textContent = 'Choose a repository to load milestones.'; + return; + } + status.textContent = 'Loading milestones…'; + try { + const milestones = await issueCapture.loadMilestones(repository); + if (qs('#create-issue-repository').value !== repository) return; + select.innerHTML += milestones.map(milestone => + '' + ).join(''); + if (selectedId) select.value = String(selectedId); + status.textContent = milestones.length ? + 'Choose the release lane for this issue.' : 'This repository has no open milestones.'; + } catch (_error) { + if (qs('#create-issue-repository').value !== repository) return; + status.textContent = 'Milestones could not be loaded. You can still create an unplanned issue.'; + } + } + + function clearSharedLaunchUrl() { + const cleanUrl = location.pathname + location.hash; + history.replaceState({}, '', cleanUrl); + } + + function openStagedSharedContent() { + if (!sharedLaunchState || sharedLaunchState.status === 'shown') return; + openCreateIssueSheet(); + if (sharedLaunchState.status === 'conflict') { + qs('#shared-content-conflict').hidden = false; + qs('#create-issue-status').textContent = 'Choose which draft to continue.'; + qs('#resume-issue-draft').focus(); + sharedLaunchState = {status: 'shown'}; + return; + } + clearSharedLaunchUrl(); + sharedLaunchState = null; + } + + function openCreateIssueSheet() { + const captureDraft = issueCapture.loadDraft(); + const repositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean); + if (captureDraft.repository && !repositories.includes(captureDraft.repository)) { + repositories.unshift(captureDraft.repository); + } + qs('#create-issue-repository').innerHTML = repositories.map(repository => + '' + ).join(''); + if (captureDraft.repository) qs('#create-issue-repository').value = captureDraft.repository; + qs('#create-issue-title').value = captureDraft.title; + qs('#create-issue-body').value = captureDraft.body; + qs('#create-issue-due-date').value = captureDraft.dueDate || ''; + loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds); + loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId); + qs('#create-issue-status').textContent = repositories.length ? '' : 'No accessible repositories are available.'; + qs('#submit-new-issue').disabled = !repositories.length; + qs('#create-issue-sheet').classList.add('open'); + creatingIssue = true; + qs('#create-issue-title').focus(); + } + + function closeCreateIssueSheet() { + qs('#create-issue-sheet').classList.remove('open'); + creatingIssue = false; + qs('#new-issue').focus(); + } + + function applyOutboxResult(result, openCreated = false) { + if (result.lease_skipped) { refreshMyWorkView(); return; } + (result.confirmed || []).forEach(confirmed => { + if (lastContextSnapshot) lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []); + }); + if (lastContextSnapshot) lastMyWork = buildMyWork(lastContextSnapshot); + refreshMyWorkView(); + if (result.confirmed?.length) { + const keys = result.confirmed.map(issue => issue.repository + '#' + issue.number).join(', '); + qs('#my-work-action-status').textContent = keys + ' created and assigned to you.'; + const confirmed = result.confirmed[result.confirmed.length - 1]; + const created = lastMyWork.find(item => + item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number + ); + if (openCreated && created) openRoutedWork(created, qs('#new-issue')); + } else if ((result.remaining || []).some(item => item.status === 'attention')) { + qs('#my-work-action-status').textContent = 'Needs attention · edit the queued issue before sending again.'; + } else { + qs('#my-work-action-status').textContent = 'Queued for sync when the connection returns.'; + } + } + + async function flushIssueOutbox() { + if (!navigator.onLine || !activeFlushLogin || !issueOutbox.list().length) return; + applyOutboxResult(await issueOutbox.flush(activeFlushLogin)); + } + + function applyAuthoredOutboxResult(result) { + if (result.lease_skipped) { refreshMyWorkView(); return; } + refreshMyWorkView(); + const attention = (result.remaining || []).some(item => item.status === 'attention'); + qs('#my-work-action-status').textContent = result.confirmed?.length ? + result.confirmed.length + ' queued message' + (result.confirmed.length === 1 ? '' : 's') + ' sent.' : + attention ? 'A queued message needs attention. Open Drafts to edit or discard it.' : + 'Message queued for sync when the connection returns.'; + } + + async function flushAuthoredOutbox() { + if (!navigator.onLine || !activeFlushLogin || !authoredOutbox.list().length) return; + applyAuthoredOutboxResult(await authoredOutbox.flush(activeFlushLogin)); + } + + function canQueueMessage(error) { + const status = Number(error?.status || 0); + return !status || status >= 500; + } + + function inlineAnchor(target) { + return { + path: target.dataset.reviewFilename, + ...(target.dataset.newPosition ? { new_position: Number(target.dataset.newPosition) } : {}), + ...(target.dataset.oldPosition ? { old_position: Number(target.dataset.oldPosition) } : {}), + }; + } + + function sameInlineAnchor(comment, anchor) { + return comment.path === anchor.path && comment.new_position === anchor.new_position && + comment.old_position === anchor.old_position; + } + + function closeInlineComposer() { + qs('#review-inline-composer').hidden = true; + qs('#review-inline-body').value = ''; + activeInlineTarget = null; + } + + function openInlineComposer(target) { + if (!draft) return; + activeInlineTarget = target; + const anchor = inlineAnchor(target); + const existing = draft.snapshot().comments.find(comment => sameInlineAnchor(comment, anchor)); + qs('#review-inline-location').textContent = anchor.path + ' · line ' + + (anchor.new_position || anchor.old_position) + (anchor.new_position ? ' (new)' : ' (old)'); + qs('#review-inline-body').value = existing?.body || ''; + qs('#delete-inline-comment').disabled = !existing; + qs('#review-inline-composer').hidden = false; + qs('#review-inline-body').focus(); + } + + async function openReviewSheet(item, trigger) { + selectedReview = item; + reviewTrigger = trigger; + qs('#review-sheet').classList.add('open'); + qs('#review-sheet-key').textContent = item.key; + qs('#review-sheet-title').textContent = item.title; + qs('#review-sheet-status').textContent = 'Loading review details…'; + qs('#retry-review-load').hidden = true; + qs('#review-sheet-body').textContent = ''; + qs('#review-files').textContent = ''; + qs('#review-progress').textContent = '0 of 0 files reviewed'; + qs('#next-unreviewed-review').disabled = true; + qs('#review-history').textContent = ''; + qs('#open-review-gitea').href = item.url; + qs('#review-decision').value = 'comment'; + qs('#review-summary').value = ''; + qs('#review-copy-fallback').hidden = true; + qs('#review-copy-fallback').value = ''; + qs('#review-handoff-link').hidden = true; + qs('#review-handoff-link').href = item.url; + qs('#review-handoff-status').textContent = ''; + qs('#review-submit-status').textContent = ''; + qs('#continue-review-to-merge').hidden = true; + qs('#submit-review').disabled = true; + closeInlineComposer(); + draft = null; + reviewFiles = []; + selectedReviewHead = ''; + qs('#close-review-sheet').focus(); + try { + const detail = await reviewController.load(selectedReview); + if (selectedReview !== item) return; + qs('#review-sheet-body').textContent = detail.body || 'No description provided.'; + qs('#review-ci-state').textContent = 'CI ' + (detail.ci_state || 'unknown'); + qs('#review-files').innerHTML = (detail.files || []).length ? detail.files.map((file, index) => + createReviewController.renderDiffFile(file, index, escapeHtml) + ).join('') : '
No changed files reported.
'; + document.querySelectorAll('.review-file-toggle').forEach(button => { + button.addEventListener('click', () => { + const panel = document.getElementById(button.getAttribute('aria-controls')); + if (panel) createReviewController.toggleDiff(button, panel); + }); + }); + progress = createReviewController.createProgress({ + storage: localStorage, + repository: item.repository, + number: item.number, + headSha: detail.head_sha || 'unknown', + files: detail.files || [], + }); + reviewFiles = detail.files || []; + selectedReviewHead = detail.head_sha || ''; + draft = createReviewController.createDraft({ + storage: localStorage, + repository: item.repository, + number: item.number, + headSha: detail.head_sha || 'unknown', + files: reviewFiles, + }); + const draftSnapshot = draft.snapshot(); + qs('#review-decision').value = draftSnapshot.decision; + qs('#review-summary').value = draftSnapshot.summary; + document.querySelectorAll('.review-note').forEach(note => { + note.value = draftSnapshot.notes[note.dataset.reviewFilename] || ''; + note.addEventListener('input', () => draft.setNote(note.dataset.reviewFilename, note.value)); + }); + document.querySelectorAll('.review-inline-target').forEach(target => { + const anchor = inlineAnchor(target); + target.classList.toggle('has-draft', draftSnapshot.comments.some(comment => + sameInlineAnchor(comment, anchor) + )); + target.addEventListener('click', () => openInlineComposer(target)); + }); + document.querySelectorAll('.review-mark').forEach(button => { + button.addEventListener('click', () => { + const snapshot = progress.markReviewed(button.dataset.reviewFilename); + showReviewProgress(snapshot); + openNextUnreviewed(snapshot); + }); + }); + const progressSnapshot = progress.snapshot(); + showReviewProgress(progressSnapshot); + openNextUnreviewed(progressSnapshot); + qs('#review-history').innerHTML = (detail.reviews || []).length ? detail.reviews.map(review => + '
' + escapeHtml(review.user?.login || 'Reviewer') + ' · ' + + escapeHtml(review.state || 'commented') + (review.body ? '
' + escapeHtml(review.body) + '
' : '') + '
' + ).join('') : '
No prior reviews.
'; + qs('#review-sheet-status').textContent = 'Ready to review · by ' + (detail.author || 'unknown author'); + qs('#submit-review').disabled = false; + } catch (error) { + if (selectedReview !== item) return; + qs('#review-sheet-status').textContent = error.message + ' Retry here or use Open in Gitea.'; + qs('#retry-review-load').hidden = false; + qs('#retry-review-load').focus(); + } + } + + function closeReviewSheet(navigate = true) { + if (navigate && createWorkRoute.parse(window.location.hash)) { + workRoute.close(); + return; + } + qs('#review-sheet').classList.remove('open'); + selectedReview = null; + progress = null; + draft = null; + reviewFiles = []; + selectedReviewHead = ''; + closeInlineComposer(); + if (reviewTrigger?.isConnected) reviewTrigger.focus(); + } + + function closeUpdateSheet(restoreTrigger = true, navigate = true) { + if (navigate && createWorkRoute.parse(window.location.hash)) { + workRoute.close(); + return; + } + qs('#update-sheet').classList.remove('open'); + selectedUpdate = null; + if (restoreTrigger && updateTrigger?.isConnected) updateTrigger.focus(); + else qs('[data-work-filter="update"]')?.focus(); + } + + function markMyWorkStale() { + qs('#my-work').setAttribute('data-stale', 'true'); + qs('#my-work-status').textContent = lastMyWork.length ? + 'Update failed · showing last known work' : 'Work inbox unavailable.'; + } + + function markNotificationsStale() { + qs('#my-work').setAttribute('data-stale', 'true'); + qs('#my-work-status').textContent = lastNotifications.length ? + 'Unread updates unavailable · showing last known updates' : + 'Unread updates unavailable · assigned work is fresh'; + } + + function paintDeltas(deltas) { + const el = qs('#ai'); + el.innerHTML = deltas.length ? deltas.map(d => '
' + escapeHtml(d.priority) + ' ' + escapeHtml(d.action) + ' ' + escapeHtml(d.target || '') + '
' + escapeHtml(d.panel) + '
').join('') : '
No suggestions yet.
'; + } + + function paintEventStream(events) { + const el = qs('#gitea-events'); + el.innerHTML = events.length ? events.slice(0, 12).map(event => + '
' + escapeHtml(event.actor?.login || 'Gitea') + ' ' + + escapeHtml(event.type || 'activity') + + '
' + escapeHtml(event.repo?.full_name || '') + + (event.created_at ? ' · ' + escapeHtml(fmt(event.created_at)) : '') + '
' + ).join('') : '
No recent Gitea activity.
'; + } + + function setEventStreamStatus(message) { + qs('#gitea-events-status').textContent = message; + } + + function renderLiveSnapshot(snapshot, changedSections = ['context', 'events', 'notifications']) { + setOfflineWorkMode(false); + offlineStatus.hidden = true; + const contextFreshness = snapshot.freshness?.sections?.context; + const eventsFreshness = snapshot.freshness?.sections?.events; + const notificationFreshness = snapshot.freshness?.sections?.notifications; + const contextChanged = changedSections.includes('context'); + const notificationsChanged = changedSections.includes('notifications'); + const eventsChanged = changedSections.includes('events'); + const workChanged = contextChanged || notificationsChanged; + const hasNotifications = Array.isArray(snapshot.notifications); + const notificationsFresh = hasNotifications && !notificationFreshness?.stale; + if (notificationsChanged && hasNotifications) { + if (notificationPagination.page > 1) { + const byId = new Map(lastNotifications.map(item => [item.id, item])); + snapshot.notifications.forEach(item => byId.set(item.id, item)); + lastNotifications = Array.from(byId.values()); + } else { + lastNotifications = snapshot.notifications; + } + if (snapshot.notification_pagination) { + const page = notificationPagination.page > 1 ? notificationPagination.page : + snapshot.notification_pagination.page; + notificationPager.reset({ + page, + total: snapshot.notification_pagination.total, + has_more: page * 50 < snapshot.notification_pagination.total, + }); + } + } + if (snapshot.context && workChanged) { + setOfflineWorkMode(false); + const retainedPlanningLogin = !snapshot.context.error ? + String(snapshot.context.user?.login || '').trim() : ''; + planningOwnerLogin = retainedPlanningLogin; + updatePlanningAvailability(); + const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale && + !contextFreshness?.degraded && !contextFreshness?.revalidating; + activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : ''; + if (activeFlushLogin) { + confirmedOwnerLogin = activeFlushLogin; + updateDeliveryReceiptControls(); + } + snapshot.context.notifications = lastNotifications; + renderContextSnapshot(snapshot.context); + if (contextFreshness?.stale) markMyWorkStale(); + else if (!notificationsFresh) markNotificationsStale(); + if (!contextFreshness?.stale && notificationsFresh) { + offlineWorkStore.save({ + ...snapshot.context, + notifications: snapshot.notifications, + notification_pagination: snapshot.notification_pagination, + }); + updateOfflineWorkControls(); + } + flushIssueOutbox(); + flushAuthoredOutbox(); + } else if (!snapshot.context) handleContextError(new Error('Context section unavailable')); + if (eventsChanged && Array.isArray(snapshot.events)) paintEventStream(snapshot.events); + if (eventsFreshness?.revalidating) { + setEventStreamStatus('Refreshing activity · showing last activity'); + } else if (eventsFreshness?.stale || eventsFreshness?.degraded) { + const retrySeconds = Number(eventsFreshness.retry_in_seconds) || 0; + setEventStreamStatus('Activity refresh failed · showing last activity' + + (retrySeconds > 0 ? ' · retrying in ' + retrySeconds + 's' : '')); + } else if (Array.isArray(snapshot.events)) { + setEventStreamStatus('Updated ' + fmt(new Date())); + } else { + setEventStreamStatus('Update failed · showing last activity'); + } + // Compatibility fallback for snapshots produced before section metadata. + if (!eventsFreshness && snapshot.freshness?.degraded && !snapshot.freshness.revalidating) { + const retrySeconds = Number(snapshot.freshness.retry_in_seconds) || 0; + setEventStreamStatus('Refresh failed · showing last known data' + + (retrySeconds > 0 ? ' · retrying in ' + retrySeconds + 's' : '')); + } else if (!eventsFreshness && snapshot.freshness?.revalidating) { + setEventStreamStatus('Refreshing · showing recent snapshot'); + } + } + + + function escapeHtml(s) { return String(s || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } + function escAttr(s) { return escapeHtml(s); } + function openModal(id) { qs('#' + id).classList.add('open'); } + function closeModal(id) { qs('#' + id).classList.remove('open'); } + + /* Creative ambient background */ + (function bg(){ + const c=qs('#bg'),ctx=c.getContext('2d'); + let w,h,time=0; + const resize=()=>{ w=c.width=innerWidth; h=c.height=innerHeight; }; + resize(); addEventListener('resize', resize); + const draw=()=>{ + time+=0.012; + ctx.clearRect(0,0,w,h); + ctx.strokeStyle='#1f3a5f'; ctx.lineWidth=1; + const step=60; + for(let x=0;x ({ x: e.clientX - canvas.getBoundingClientRect().left, y: e.clientY - canvas.getBoundingClientRect().top }); + canvas.addEventListener('pointerdown', (e)=>{ drawing=true; const pos=p(e); ctx.beginPath(); ctx.moveTo(pos.x, pos.y); }); + canvas.addEventListener('pointermove', (e)=>{ if (!drawing) return; const pos=p(e); ctx.lineTo(pos.x, pos.y); ctx.stroke(); }); + canvas.addEventListener('pointerup', () => drawing=false); + canvas.addEventListener('pointerleave', () => drawing=false); + qs('#wb-clear').addEventListener('click', () => { const r=canvas.getBoundingClientRect(); ctx.clearRect(0,0,r.width,r.height); }); + qs('#wb-save').addEventListener('click', () => { const a=document.createElement('a'); a.href=canvas.toDataURL(); a.download='whiteboard.png'; a.click(); }); + } + + /* Markdown */ + qs('#md-input').addEventListener('input', renderMD); + function renderMD() { + const raw = qs('#md-input').value || ''; + qs('#md-preview').innerHTML = '
' + escapeHtml(raw) + '
' + renderMarkdown(raw) + '
'; + } + + /* Commands */ + const commands = [ + { name: 'Open whiteboard', run: () => { openModal('whiteboard-modal'); initWhiteboard(); } }, + { name: 'Open markdown widget', run: () => { qs('#md-input').focus(); } }, + { name: 'Refresh now', run: load }, + { name: 'Scroll issues', run: () => qs('#work').scrollIntoView({ behavior:'smooth', block:'start' }) }, + ]; + let commandSearchState = { status:'idle', query:'', items:[] }; + let commandItems = []; + let commandSelection = -1; + async function searchGlobalWork(query, signal) { + const response = await fetch('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10', { + headers: { Accept:'application/json' }, + signal, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || 'Search is temporarily unavailable.'); + return { + items: Array.isArray(payload.items) ? payload.items : [], + partial: payload.partial === true, + }; + } + const commandSearch = filterCommands.createGlobalSearchController({ + search: searchGlobalWork, + onState: state => { + commandSearchState = state; + renderCommands(state.query); + }, + }); + function safeSearchUrl(value) { + try { + const url = new URL(value); + return ['http:', 'https:'].includes(url.protocol) ? url.href : ''; + } catch (_) { return ''; + } + } + let searchPreviewDetail = null; + function searchPreviewPath(item) { + const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/'); + return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + + '/preview?kind=' + encodeURIComponent(item.kind); + } + function renderSearchPreview(state) { + const sheet = qs('#search-preview'); + const status = qs('#search-preview-status'); + const claimButton = qs('#claim-search-result'); + if (state.status === 'closed') { + sheet.classList.remove('open'); + return; + } + sheet.classList.add('open'); + claimButton.hidden = true; + claimButton.disabled = false; + if (state.status === 'loading') { + searchPreviewDetail = null; + qs('#search-preview-key').textContent = state.item.repository + ' #' + state.item.number; + qs('#search-preview-title').textContent = state.item.title || 'Work preview'; + qs('#search-preview-meta').textContent = ''; + qs('#search-preview-body').textContent = ''; + qs('#open-search-result-gitea').href = safeSearchUrl(state.item.url) || '#'; + status.textContent = 'Loading preview…'; + return; + } + if (state.status === 'error') { + status.textContent = state.error?.message || 'Preview unavailable. Open this result in Gitea or retry.'; + return; + } + const detail = state.detail; + if (!detail) return; + searchPreviewDetail = detail; + qs('#search-preview-key').textContent = detail.repository + ' #' + detail.number; + qs('#search-preview-title').textContent = detail.title || 'Untitled work item'; + qs('#search-preview-meta').textContent = + (detail.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + (detail.state || 'unknown') + + (detail.author ? ' · by ' + detail.author : '') + + (detail.labels?.length ? ' · ' + detail.labels.join(', ') : '') + + (detail.assignees?.length ? ' · assigned to ' + detail.assignees.join(', ') : ''); + qs('#search-preview-body').textContent = detail.body || 'No description provided.'; + qs('#open-search-result-gitea').href = safeSearchUrl(detail.url) || '#'; + claimButton.hidden = !(detail.claimable || (detail.assigned_to_me && detail.kind === 'issue')); + claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'; + claimButton.disabled = state.status === 'claiming'; + status.textContent = state.status === 'claiming' ? 'Assigning this issue to you…' : + (state.status === 'claimed' ? 'Assignment confirmed. Opening My Work…' : + (detail.claimable ? 'This issue is open and unassigned.' : + (detail.assigned_to_me ? 'This issue is already in My Work.' : 'Read-only preview.'))); + } + const searchPreview = createSearchPreview({ + fetchJson: item => fetchReviewJson(searchPreviewPath(item), { headers:{ Accept:'application/json' } }), + claim: detail => fetchReviewJson( + 'api/v1/repos/' + detail.repository.split('/').map(encodeURIComponent).join('/') + + '/issues/' + encodeURIComponent(detail.number) + '/claim', + { method:'PATCH', headers:{ Accept:'application/json' } } + ), + onState: renderSearchPreview, + }); + function closeSearchPreview() { + searchPreview.close(); + qs('#cmd-palette').classList.add('open'); + qs('#cmd-input').setAttribute('aria-expanded', 'true'); + renderCommands(qs('#cmd-input').value); + qs('#cmd-input').focus(); + } + async function openPreviewIssueInMyWork(detail) { + await load(); + const item = lastMyWork.find(candidate => + candidate.kind === 'issue' && candidate.key === detail.repository + '#' + detail.number + ); + if (!item) return false; + searchPreview.close(); + openRoutedWork(item, qs('#find-work')); + return true; + } + function runCommandItem(item) { + if (item.command) { + item.command.run(); + qs('#cmd-input').value = ''; + } else { + searchPreview.open(item.result).catch(() => {}); + } + qs('#cmd-palette').classList.remove('open'); + qs('#cmd-input').setAttribute('aria-expanded', 'false'); + } + function renderCommands(filter) { + const el = qs('#cmd-results'); + const local = filterCommands(commands, filter).map(command => ({ command })); + const remote = commandSearchState.query === String(filter || '').trim() + ? commandSearchState.items.map(result => ({ result })) : []; + commandItems = local.concat(remote); + if (commandSelection >= commandItems.length) commandSelection = -1; + let html = local.length ? '
Commands
' : ''; + html += local.map((item, idx) => '
' + escapeHtml(item.command.name) + 'Command
').join(''); + if (remote.length) html += '
Issues and pull requests
'; + html += remote.map((item, remoteIdx) => { + const idx = local.length + remoteIdx; + const result = item.result; + return '
' + escapeHtml(result.title) + '' + escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' + escapeHtml(result.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + escapeHtml(result.state) + '
'; + }).join(''); + if (commandSearchState.status === 'loading') html += '
Searching accessible work…
'; + else if (commandSearchState.status === 'error') html += '
Search unavailable. Keep typing or retry.
'; + else if (commandSearchState.partial) html += '
Some results are temporarily unavailable.
'; + else if (String(filter || '').trim().length >= 2 && !remote.length) html += '
No matching issues or pull requests.
'; + el.innerHTML = html; + el.querySelectorAll('.cmd-item').forEach((item) => { + item.addEventListener('click', () => runCommandItem(commandItems[Number(item.dataset.idx)])); + }); + } + function openCommandPalette() { + qs('#cmd-palette').classList.add('open'); + qs('#cmd-input').setAttribute('aria-expanded', 'true'); + qs('#cmd-input').focus(); + commandSelection = -1; + renderCommands(qs('#cmd-input').value); + } + qs('#open-palette').addEventListener('click', openCommandPalette); + qs('#cmd-input').addEventListener('input', (e) => { + commandSelection = -1; + renderCommands(e.target.value); + commandSearch.setQuery(e.target.value); + }); + qs('#cmd-input').addEventListener('keydown', event => { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + commandSelection = filterCommands.nextSelection(commandSelection, event.key, commandItems.length); + renderCommands(event.currentTarget.value); + const selected = qs('#cmd-results .selected'); + if (selected) selected.scrollIntoView({ block:'nearest' }); + } else if (event.key === 'Enter' && commandSelection >= 0) { + event.preventDefault(); + runCommandItem(commandItems[commandSelection]); + } + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && qs('#cmd-palette').classList.contains('open')) { + e.preventDefault(); + qs('#cmd-palette').classList.remove('open'); + qs('#cmd-input').setAttribute('aria-expanded', 'false'); + return; + } + if (e.key === 'Escape' && qs('#search-preview').classList.contains('open')) { + e.preventDefault(); + closeSearchPreview(); + return; + } + if (e.key === 'Escape' && findingWork) { + e.preventDefault(); + closeFindWorkSheet(); + return; + } + if (e.key === 'Escape' && creatingIssue) { + e.preventDefault(); + saveIssueCaptureDraft(); + closeCreateIssueSheet(); + return; + } + if (e.key === 'Escape' && selectedIssue) { + e.preventDefault(); + closeIssueSheet(); + return; + } + if (e.key === 'Escape' && selectedPull) { + e.preventDefault(); + closePullSheet(); + return; + } + if (e.key === 'Escape' && selectedReview) { + e.preventDefault(); + closeReviewSheet(); + return; + } + if (e.key === 'Escape' && selectedUpdate) { + e.preventDefault(); + closeUpdateSheet(); + return; + } + if ((e.metaKey||e.ctrlKey) && e.key==='k') { + e.preventDefault(); + if (qs('#cmd-palette').classList.contains('open')) { + qs('#cmd-palette').classList.remove('open'); + qs('#cmd-input').setAttribute('aria-expanded', 'false'); + } else openCommandPalette(); + } + }); + qs('#close-search-preview').addEventListener('click', closeSearchPreview); + document.querySelectorAll('.share-work-route').forEach(button => { + button.addEventListener('click', async () => { + const status = qs('#work-route-share-status'); + try { + const result = await createWorkRoute.share(window.location.href, navigator, navigator.clipboard); + status.textContent = result === 'shared' ? 'Work link shared.' : 'Work link copied.'; + } catch (error) { + status.textContent = error?.name === 'AbortError' ? 'Share canceled.' : 'Could not share this work link.'; + } + }); + }); + qs('#claim-search-result').addEventListener('click', async () => { + if (!searchPreviewDetail || (!searchPreviewDetail.claimable && !searchPreviewDetail.assigned_to_me)) return; + try { + const claimed = searchPreviewDetail; + if (claimed.claimable) await searchPreview.claim(claimed); + const opened = await openPreviewIssueInMyWork(claimed); + if (!opened) { + qs('#search-preview-status').textContent = + 'Assignment confirmed. Refresh My Work to open the issue.'; + } + } catch (error) { + qs('#search-preview-status').textContent = error.message + ' Retry assignment.'; + } + }); + qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal')); + qs('#find-work').addEventListener('click', openFindWorkSheet); + qs('#close-find-work').addEventListener('click', closeFindWorkSheet); + qs('#load-more-available').addEventListener('click', async event => { + event.currentTarget.disabled = true; + qs('#find-work-status').textContent = 'Loading more available issues…'; + try { + await findWorkController.loadMore(); + qs('#find-work-status').textContent = findWorkController.items().length + ' of ' + + availablePagination.total + ' available issues loaded.'; + } catch (error) { + qs('#find-work-status').textContent = error.message + ' Retry loading more.'; + } finally { + event.currentTarget.disabled = false; + } + }); + qs('#new-issue').addEventListener('click', openCreateIssueSheet); + qs('#use-shared-content').addEventListener('click', () => { + issueCapture.acceptSharedContent(); + qs('#shared-content-conflict').hidden = true; + sharedLaunchState = null; + clearSharedLaunchUrl(); + openCreateIssueSheet(); + qs('#create-issue-status').textContent = 'Shared content added. Choose a repository and finish planning.'; + }); + qs('#resume-issue-draft').addEventListener('click', () => { + issueCapture.discardSharedContent(); + qs('#shared-content-conflict').hidden = true; + sharedLaunchState = null; + clearSharedLaunchUrl(); + qs('#create-issue-status').textContent = 'Existing draft restored.'; + qs('#create-issue-title').focus(); + }); + qs('#cancel-new-issue').addEventListener('click', () => { + if (editingOutboxId) { + issueCapture.clearDraft(); + editingOutboxId = null; + } else saveIssueCaptureDraft(); + closeCreateIssueSheet(); + }); + ['#create-issue-title', '#create-issue-body', '#create-issue-due-date'].forEach(selector => + qs(selector).addEventListener('input', saveIssueCaptureDraft) + ); + qs('#create-issue-repository').addEventListener('change', event => { + loadIssueLabels(event.target.value); + loadIssueMilestones(event.target.value); + saveIssueCaptureDraft(); + }); + qs('#create-issue-label-list').addEventListener('change', saveIssueCaptureDraft); + qs('#create-issue-milestone').addEventListener('change', saveIssueCaptureDraft); + qs('#create-issue-form').addEventListener('submit', async event => { + event.preventDefault(); + const captureDraft = { + repository: qs('#create-issue-repository').value, + title: qs('#create-issue-title').value.trim(), + body: qs('#create-issue-body').value.trim(), + labelIds: selectedIssueLabelIds(), + milestoneId: Number(qs('#create-issue-milestone').value) || null, + dueDate: qs('#create-issue-due-date').value, + }; + if (!captureDraft.repository || !captureDraft.title) { + qs('#create-issue-status').textContent = 'Choose a repository and add a title.'; + qs('#create-issue-title').focus(); + return; + } + const button = qs('#submit-new-issue'); + button.disabled = true; + qs('#create-issue-status').textContent = 'Saving for background delivery…'; + try { + const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, captureDraft) : + await issueOutbox.enqueueDurably(captureDraft); + const queued = admission.item; + if (!admission.background) { + editingOutboxId = queued.id; + refreshMyWorkView(); + qs('#create-issue-status').textContent = 'Saved for next launch; background delivery unavailable.'; + button.disabled = false; + return; + } + editingOutboxId = null; + issueCapture.clearDraft(); + closeCreateIssueSheet(); + refreshMyWorkView(); + qs('#my-work-action-status').textContent = 'Queued for sync.'; + if (navigator.onLine) applyOutboxResult(await issueOutbox.retry(queued.id, activeFlushLogin), true); + } catch (error) { + qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.'; + button.disabled = false; + qs('#create-issue-title').focus(); + } + }); + qs('#close-issue-sheet').addEventListener('click', closeIssueSheet); + qs('#retry-issue-load').addEventListener('click', () => { + if (selectedIssue) openIssueSheet(selectedIssue, issueTrigger); + }); + qs('#issue-planning').addEventListener('toggle', event => { + if (event.currentTarget.open) loadIssuePlanning(); + }); + qs('#retry-issue-planning').addEventListener('click', loadIssuePlanning); + qs('#edit-issue-content').addEventListener('click', () => { + if (!selectedIssue || !selectedIssueDetail?.updated_at) return; + const draft = issueController.loadEditDraft(selectedIssue) || { + title: selectedIssueDetail.title || '', + body: selectedIssueDetail.body || '', + expectedUpdatedAt: selectedIssueDetail.updated_at, + }; + qs('#issue-edit-title').value = draft.title; + qs('#issue-edit-body').value = draft.body; + qs('#issue-edit-form').hidden = false; + qs('#issue-edit-status').textContent = 'Edit the issue, then save.'; + qs('#issue-edit-title').focus(); + }); + ['#issue-edit-title', '#issue-edit-body'].forEach(selector => + qs(selector).addEventListener('input', () => { + if (!selectedIssue || !selectedIssueDetail?.updated_at) return; + issueController.saveEditDraft(selectedIssue, { + title: qs('#issue-edit-title').value, + body: qs('#issue-edit-body').value, + expectedUpdatedAt: issueController.loadEditDraft(selectedIssue)?.expectedUpdatedAt || selectedIssueDetail.updated_at, + }); + }) + ); + qs('#cancel-issue-content').addEventListener('click', () => { + qs('#issue-edit-form').hidden = true; + qs('#edit-issue-content').focus(); + }); + qs('#issue-edit-form').addEventListener('submit', async event => { + event.preventDefault(); + if (!selectedIssue || !selectedIssueDetail?.updated_at || !lastContextSnapshot) return; + const title = qs('#issue-edit-title').value.trim(); + const body = qs('#issue-edit-body').value.trim(); + if (!title) { + qs('#issue-edit-status').textContent = 'Add a title before saving.'; + qs('#issue-edit-title').focus(); + return; + } + const editing = selectedIssue; + const savedDraft = issueController.loadEditDraft(editing); + const draft = { + title, + body, + expectedUpdatedAt: savedDraft?.expectedUpdatedAt || selectedIssueDetail.updated_at, + }; + const button = qs('#save-issue-content'); + button.disabled = true; + qs('#issue-edit-status').textContent = 'Saving issue…'; + try { + const confirmed = await issueController.updateContent(editing, draft); + lastContextSnapshot = buildMyWork.replaceIssueContent( + lastContextSnapshot, editing.repository, editing.number, confirmed + ); + selectedIssue = { ...editing, ...confirmed, key: editing.key }; + selectedIssueDetail = { ...selectedIssueDetail, ...confirmed }; + qs('#issue-sheet-title').textContent = confirmed.title; + qs('#issue-sheet-body').textContent = confirmed.body || 'No description provided.'; + paintMyWork(lastContextSnapshot); + qs('#issue-edit-form').hidden = true; + qs('#issue-sheet-status').textContent = 'Issue saved.'; + qs('#edit-issue-content').focus(); + } catch (error) { + qs('#issue-edit-status').textContent = error.message + ' Your draft is safe; reload latest or open in Gitea.'; + qs('#retry-issue-load').hidden = false; + qs('#issue-edit-title').focus(); + } finally { + button.disabled = false; + } + }); + qs('#issue-comment').addEventListener('input', event => { + if (selectedIssue) issueController.saveDraft(selectedIssue, event.target.value); + }); + qs('#load-older-issue-comments').addEventListener('click', async () => { + if (!issueConversation) return; + const button = qs('#load-older-issue-comments'); + const panel = qs('#issue-sheet .issue-sheet-panel'); + const previousHeight = panel.scrollHeight; + button.disabled = true; + qs('#issue-conversation-status').textContent = 'Loading older messages…'; + try { + renderIssueConversation(await issueConversation.loadOlder()); + panel.scrollTop += panel.scrollHeight - previousHeight; + } catch (error) { + qs('#issue-conversation-status').textContent = error.message + ' Loaded messages and your draft are safe; retry.'; + } finally { + button.disabled = false; + } + }); + qs('#save-issue-labels').addEventListener('click', async () => { + if (!selectedIssue || !lastContextSnapshot) return; + const editing = selectedIssue; + const button = qs('#save-issue-labels'); + button.disabled = true; + qs('#issue-label-status').textContent = 'Saving labels…'; + try { + const confirmed = await issueController.updateLabels(selectedIssue, selectedEditIssueLabelIds()); + lastContextSnapshot = buildMyWork.replaceIssueLabels( + lastContextSnapshot, editing.repository, editing.number, confirmed.labels + ); + selectedIssue = { ...editing, labels: confirmed.labels }; + qs('#issue-labels').innerHTML = confirmed.labels.map(label => + '' + escapeHtml(label) + '' + ).join(' '); + paintMyWork(lastContextSnapshot); + qs('#issue-label-status').textContent = 'Labels saved. My Work reprioritized.'; + } catch (error) { + qs('#issue-label-status').textContent = error.message + ' Your selection is safe; retry.'; + } finally { + button.disabled = false; + } + }); + + async function saveSelectedIssueDueDate(dueDate) { + if (!selectedIssue || !lastContextSnapshot) return; + const editing = selectedIssue; + const saveButton = qs('#save-issue-due-date'); + const clearButton = qs('#clear-issue-due-date'); + saveButton.disabled = true; + clearButton.disabled = true; + qs('#issue-due-status').textContent = dueDate ? 'Saving due date…' : 'Clearing due date…'; + try { + const confirmed = await issueController.updateDueDate(editing, dueDate); + lastContextSnapshot = buildMyWork.replaceIssueDueDate( + lastContextSnapshot, editing.repository, editing.number, confirmed.due_date + ); + selectedIssue = { ...editing, due_date: confirmed.due_date }; + selectedIssueDetail = { ...selectedIssueDetail, due_date: confirmed.due_date }; + qs('#issue-due-date').value = String(confirmed.due_date || '').slice(0, 10); + paintMyWork(lastContextSnapshot); + qs('#issue-due-status').textContent = confirmed.due_date ? + 'Due date saved. My Work reprioritized.' : 'Due date cleared.'; + } catch (error) { + qs('#issue-due-status').textContent = error.message + ' Your selection is safe; retry.'; + qs('#issue-due-date').focus(); + } finally { + saveButton.disabled = false; + clearButton.disabled = !selectedIssueDetail?.due_date; + } + } + + qs('#save-issue-due-date').addEventListener('click', () => { + const value = qs('#issue-due-date').value; + if (!value) { + qs('#issue-due-status').textContent = 'Choose a date or use Clear due date.'; + qs('#issue-due-date').focus(); + return; + } + saveSelectedIssueDueDate(value + 'T23:59:59Z'); + }); + qs('#clear-issue-due-date').addEventListener('click', () => saveSelectedIssueDueDate(null)); + qs('#save-issue-milestone').addEventListener('click', async () => { + if (!selectedIssue || !lastContextSnapshot) return; + const editing = selectedIssue; + const button = qs('#save-issue-milestone'); + const raw = qs('#issue-milestone').value; + const milestoneId = raw ? Number(raw) : null; + button.disabled = true; + qs('#issue-milestone').disabled = true; + qs('#issue-milestone-status').textContent = milestoneId ? 'Saving milestone…' : 'Clearing milestone…'; + try { + const confirmed = await issueController.updateMilestone(editing, milestoneId); + lastContextSnapshot = buildMyWork.replaceIssueMilestone( + lastContextSnapshot, editing.repository, editing.number, confirmed.milestone + ); + selectedIssue = { ...editing, milestone: confirmed.milestone }; + selectedIssueDetail = { ...selectedIssueDetail, milestone: confirmed.milestone }; + paintMyWork(lastContextSnapshot); + qs('#issue-milestone-status').textContent = confirmed.milestone ? + 'Planned for ' + confirmed.milestone.title + '. Release lane updated.' : 'Milestone cleared.'; + } catch (error) { + qs('#issue-milestone-status').textContent = error.message + ' Your selection is safe; retry.'; + qs('#issue-milestone').focus(); + } finally { + button.disabled = false; + qs('#issue-milestone').disabled = false; + } + }); + qs('#send-issue-comment').addEventListener('click', async () => { + if (!selectedIssue) return; + const body = qs('#issue-comment').value.trim(); + if (!body) { + qs('#issue-comment-status').textContent = 'Write a comment before posting.'; + qs('#issue-comment').focus(); + return; + } + const button = qs('#send-issue-comment'); + button.disabled = true; + qs('#issue-comment-status').textContent = 'Posting comment…'; + try { + const comment = await issueController.comment(selectedIssue, body); + if (issueConversation) renderIssueConversation(issueConversation.append(comment)); + qs('#issue-comment').value = ''; + qs('#issue-comment-status').textContent = 'Comment posted.'; + } catch (error) { + if (canQueueMessage(error)) { + const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation'); + qs('#issue-comment-status').textContent = 'Saving for background delivery…'; + const admission = await authoredOutbox.enqueueDurably({ kind:'issue-comment', repository:selectedIssue.repository, + number:selectedIssue.number, body, operationId }); + refreshMyWorkView(); + if (admission.background) { + qs('#issue-comment').value = ''; + qs('#issue-comment-status').textContent = 'Queued for sync when the connection returns.'; + } else { + qs('#issue-comment-status').textContent = 'Saved for next launch; background delivery unavailable.'; + qs('#issue-comment').focus(); + } + } else { + qs('#issue-comment-status').textContent = error.message + ' Your draft is safe; retry.'; + qs('#issue-comment').focus(); + } + } finally { + button.disabled = false; + } + }); + qs('#release-issue').addEventListener('click', async () => { + if (!selectedIssue || !window.confirm('Release ' + selectedIssue.key + ' from your My Work?')) return; + const releasing = selectedIssue; + const button = qs('#release-issue'); + button.disabled = true; + qs('#issue-sheet-status').textContent = 'Releasing assignment…'; + try { + const confirmed = await issueController.release(selectedIssue, lastContextSnapshot?.user?.login); + lastContextSnapshot = buildMyWork.removeIssue( + lastContextSnapshot, releasing.repository, releasing.number + ); + closeIssueSheet(); + paintMyWork(lastContextSnapshot); + qs('#my-work-action-status').textContent = releasing.key + ' released.' + + (confirmed.available ? ' It is available in Find Work.' : ' Other assignees remain.'); + } catch (error) { + qs('#issue-sheet-status').textContent = error.message + ' The issue remains in My Work; retry.'; + button.disabled = false; + button.focus(); + } + }); + qs('#load-issue-handoff').addEventListener('click', async () => { + if (!selectedIssue) return; + const button = qs('#load-issue-handoff'); + const select = qs('#issue-handoff-recipient'); + button.disabled = true; + qs('#issue-handoff-status').textContent = 'Loading eligible teammates…'; + try { + const candidates = await issueController.loadHandoffCandidates(selectedIssue); + select.textContent = ''; + const placeholder = document.createElement('option'); + placeholder.value = ''; + placeholder.textContent = candidates.length ? 'Select a teammate' : 'No eligible teammates'; + select.appendChild(placeholder); + candidates.forEach(candidate => { + const option = document.createElement('option'); + option.value = candidate.login; + option.textContent = candidate.name + (candidate.name === candidate.login ? '' : ' (@' + candidate.login + ')'); + select.appendChild(option); + }); + select.disabled = !candidates.length; + qs('#confirm-issue-handoff').disabled = true; + qs('#issue-handoff-status').textContent = candidates.length ? + 'Choose who should own this issue next.' : 'No other eligible assignees were found.'; + if (candidates.length) select.focus(); + } catch (error) { + qs('#issue-handoff-status').textContent = error.message + ' Retry loading teammates.'; + button.disabled = false; + button.focus(); + } + }); + qs('#issue-handoff-recipient').addEventListener('change', event => { + qs('#confirm-issue-handoff').disabled = !event.target.value; + }); + qs('#confirm-issue-handoff').addEventListener('click', async () => { + const recipient = qs('#issue-handoff-recipient').value; + if (!selectedIssue || !recipient || !window.confirm('Hand off ' + selectedIssue.key + ' to @' + recipient + '?')) return; + const handingOff = selectedIssue; + const button = qs('#confirm-issue-handoff'); + button.disabled = true; + qs('#issue-handoff-status').textContent = 'Confirming handoff…'; + try { + await issueController.handoff(selectedIssue, recipient, lastContextSnapshot?.user?.login); + lastContextSnapshot = buildMyWork.removeIssue( + lastContextSnapshot, handingOff.repository, handingOff.number + ); + closeIssueSheet(); + paintMyWork(lastContextSnapshot); + qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + '.'; + } catch (error) { + qs('#issue-handoff-status').textContent = error.message + ' The issue remains in My Work; retry.'; + button.disabled = false; + button.focus(); + } + }); + qs('#close-issue').addEventListener('click', async () => { + if (!selectedIssue || !window.confirm('Close ' + selectedIssue.key + '?')) return; + const closing = selectedIssue; + const button = qs('#close-issue'); + button.disabled = true; + qs('#issue-sheet-status').textContent = 'Closing issue…'; + try { + await issueController.close(selectedIssue); + closeIssueSheet(); + lastMyWork = lastMyWork.filter(item => + !(item.kind === 'issue' && item.repository === closing.repository && item.number === closing.number) + ); + refreshMyWorkView(); + qs('#my-work-action-status').textContent = closing.key + ' closed.'; + } catch (error) { + qs('#issue-sheet-status').textContent = error.message + ' The issue remains in My Work; retry.'; + button.disabled = false; + button.focus(); + } + }); + qs('#close-pull-sheet').addEventListener('click', closePullSheet); + qs('#retry-pull-load').addEventListener('click', () => { + if (selectedPull) openPullSheet(selectedPull, pullTrigger); + }); + qs('#pull-review').addEventListener('toggle', event => { + if (event.currentTarget.open) loadPullReview(); + }); + qs('#pull-review-retry').addEventListener('click', loadPullReview); + qs('#next-unreviewed-pull-file').addEventListener('click', focusNextUnreviewedPullFile); + qs('#load-older-pull-comments').addEventListener('click', async () => { + if (!pullConversation) return; + const button = qs('#load-older-pull-comments'); + const panel = qs('#pull-sheet .pull-sheet-panel'); + const previousHeight = panel.scrollHeight; + button.disabled = true; + qs('#pull-conversation-status').textContent = 'Loading older messages…'; + try { + renderPullConversation(await pullConversation.loadOlder()); + panel.scrollTop += panel.scrollHeight - previousHeight; + } catch (error) { + qs('#pull-conversation-status').textContent = error.message + ' Loaded messages and your draft are safe; retry.'; + } finally { + button.disabled = false; + } + }); + qs('#pull-comment').addEventListener('input', event => { + if (selectedPull) pullController.saveDraft(selectedPull, event.target.value); + }); + qs('#send-pull-comment').addEventListener('click', async () => { + if (!selectedPull) return; + const body = qs('#pull-comment').value.trim(); + if (!body) { + qs('#pull-comment-status').textContent = 'Write a comment before posting.'; + qs('#pull-comment').focus(); + return; + } + const button = qs('#send-pull-comment'); + button.disabled = true; + qs('#pull-comment-status').textContent = 'Posting comment…'; + try { + const comment = await pullController.comment(selectedPull, body); + if (pullConversation) renderPullConversation(pullConversation.append(comment)); + qs('#pull-comment').value = ''; + qs('#pull-comment-status').textContent = 'Comment posted.'; + } catch (error) { + if (canQueueMessage(error)) { + const operationId = localStorage.getItem('stackchain.pull-comment.v1:' + selectedPull.repository + '#' + selectedPull.number + ':operation'); + qs('#pull-comment-status').textContent = 'Saving for background delivery…'; + const admission = await authoredOutbox.enqueueDurably({ kind:'pull-comment', repository:selectedPull.repository, + number:selectedPull.number, body, operationId }); + refreshMyWorkView(); + if (admission.background) { + qs('#pull-comment').value = ''; + qs('#pull-comment-status').textContent = 'Queued for sync when the connection returns.'; + } else { + qs('#pull-comment-status').textContent = 'Saved for next launch; background delivery unavailable.'; + qs('#pull-comment').focus(); + } + } else { + qs('#pull-comment-status').textContent = error.message + ' Your draft is safe; retry.'; + qs('#pull-comment').focus(); + } + } finally { + button.disabled = false; + } + }); + qs('#merge-pull').addEventListener('click', async () => { + if (!selectedPull || !selectedPullDetail?.head_sha || !window.confirm('Merge ' + selectedPull.key + ' at current head?')) return; + const merging = selectedPull; + const button = qs('#merge-pull'); + button.disabled = true; + qs('#pull-sheet-status').textContent = 'Merging pull request…'; + try { + await pullController.merge(selectedPull, selectedPullDetail.head_sha); + closePullSheet(); + lastMyWork = lastMyWork.filter(item => + !(item.kind === 'pull' && item.repository === merging.repository && item.number === merging.number) + ); + refreshMyWorkView(); + qs('#my-work-action-status').textContent = merging.key + ' merged.'; + } catch (error) { + qs('#pull-sheet-status').textContent = error.message + ' The pull request remains in My Work; refresh and retry.'; + button.disabled = false; + button.focus(); + } + }); + qs('#keep-update-unread').addEventListener('click', () => closeUpdateSheet(true)); + qs('#retry-update-load').addEventListener('click', () => { + if (selectedUpdate) notificationReader.open(selectedUpdate, lastMyWork); + }); + qs('#load-older-update-comments').addEventListener('click', async () => { + const button = qs('#load-older-update-comments'); + const panel = qs('#update-sheet .update-sheet-panel'); + const previousHeight = panel.scrollHeight; + const previousTop = panel.scrollTop; + button.disabled = true; + await notificationReader.loadOlder(); + panel.scrollTop = previousTop + (panel.scrollHeight - previousHeight); + button.disabled = false; + }); + qs('#update-reply').addEventListener('input', event => { + if (selectedUpdate) notificationReplier.saveDraft(selectedUpdate, event.target.value); + }); + qs('#send-update-reply').addEventListener('click', async () => { + if (!selectedUpdate) return; + const body = qs('#update-reply').value.trim(); + if (!body) { + qs('#update-reply-status').textContent = 'Write a reply before sending.'; + qs('#update-reply').focus(); + return; + } + qs('#send-update-reply').disabled = true; + const result = await notificationReplier.submit(selectedUpdate, body); + qs('#send-update-reply').disabled = false; + if (result?.queued) { + qs('#update-reply').value = ''; + refreshMyWorkView(); + qs('#my-work-action-status').textContent = 'Reply queued for sync.'; + } else if (result) { + notificationReader.appendReply(result); + qs('#update-reply').value = ''; + qs('#mark-update-read-next').focus(); + } else { + qs('#update-reply').focus(); + } + }); + qs('#mark-update-read-next').addEventListener('click', async () => { + qs('#mark-update-read-next').disabled = true; + try { + await notificationReader.markReadAndNext(lastMyWork); + } finally { + qs('#mark-update-read-next').disabled = false; + } + }); + qs('#close-review-sheet').addEventListener('click', closeReviewSheet); + qs('#retry-review-load').addEventListener('click', () => { + if (selectedReview) openReviewSheet(selectedReview, reviewTrigger); + }); + qs('#next-unreviewed-review').addEventListener('click', () => { + if (progress) openNextUnreviewed(progress.snapshot()); + }); + qs('#review-summary').addEventListener('input', event => draft?.setSummary(event.target.value)); + qs('#review-decision').addEventListener('change', event => draft?.setDecision(event.target.value)); + qs('#save-inline-comment').addEventListener('click', () => { + if (!draft || !activeInlineTarget) return; + const body = qs('#review-inline-body').value.trim(); + if (!body) { + qs('#review-inline-body').focus(); + return; + } + draft.setInlineComment(inlineAnchor(activeInlineTarget), body); + activeInlineTarget.classList.add('has-draft'); + const target = activeInlineTarget; + closeInlineComposer(); + target.focus(); + }); + qs('#delete-inline-comment').addEventListener('click', () => { + if (!draft || !activeInlineTarget) return; + draft.removeInlineComment(inlineAnchor(activeInlineTarget)); + activeInlineTarget.classList.remove('has-draft'); + const target = activeInlineTarget; + closeInlineComposer(); + target.focus(); + }); + qs('#cancel-inline-comment').addEventListener('click', () => { + const target = activeInlineTarget; + closeInlineComposer(); + target?.focus(); + }); + qs('#submit-review').addEventListener('click', async () => { + if (!draft || !selectedReview || !selectedReviewHead) return; + const snapshot = draft.snapshot(); + const labels = { approve: 'Approve', request_changes: 'Request changes' }; + if (labels[snapshot.decision] && !window.confirm( + labels[snapshot.decision] + ' ' + selectedReview.repository + '#' + selectedReview.number + '?' + )) return; + const button = qs('#submit-review'); + button.disabled = true; + qs('#review-submit-status').textContent = 'Submitting review…'; + try { + const item = selectedReview; + const progressSnapshot = progress?.snapshot() || { reviewed: [] }; + const result = await reviewController.submit(selectedReview, { + decision: snapshot.decision, + body: createReviewController.formatFeedback(snapshot, reviewFiles), + expected_head_sha: selectedReviewHead, + comments: snapshot.comments, + }); + const canContinueToMerge = createReviewController.prepareMergeContinuation({ + storage: localStorage, + item, + headSha: selectedReviewHead, + reviewed: progressSnapshot.reviewed, + decision: snapshot.decision, + }); + draft.clear(); + progress?.clear(); + qs('#review-decision').value = 'comment'; + qs('#review-summary').value = ''; + document.querySelectorAll('.review-note').forEach(note => { note.value = ''; }); + if (progress) showReviewProgress(progress.snapshot()); + qs('#review-submit-status').textContent = 'Review submitted · ' + (result.state || 'complete') + '.'; + if (canContinueToMerge) { + qs('#continue-review-to-merge').hidden = false; + qs('#continue-review-to-merge').focus(); + } else { + await load(); + if (workSession.active()) workSession.complete(); + else button.focus(); + } + } catch (error) { + qs('#review-submit-status').textContent = error.message + ' Your draft is safe; retry or open in Gitea.'; + button.disabled = false; + button.focus(); + } + }); + qs('#continue-review-to-merge').addEventListener('click', () => { + const item = selectedReview; + if (!item) return; + workRoute.open({ ...item, kind:'pull', is_review:false }, { replace:true }); + }); + qs('#copy-review-feedback').addEventListener('click', async () => { + if (!draft || !selectedReview || reviewHandoffPending) return; + reviewHandoffPending = true; + qs('#copy-review-feedback').disabled = true; + const fallback = qs('#review-copy-fallback'); + const directLink = qs('#review-handoff-link'); + fallback.hidden = true; + directLink.hidden = true; + let handoffWindow = null; + try { + const result = await createReviewController.copyAndContinue({ + text: createReviewController.formatFeedback(draft.snapshot(), reviewFiles), + url: selectedReview.url, + copy: text => navigator.clipboard.writeText(text), + open: () => { + handoffWindow = window.open('about:blank', '_blank'); + if (handoffWindow) handoffWindow.opener = null; + return handoffWindow; + }, + fallback: text => { + fallback.value = text; + fallback.hidden = false; + fallback.focus(); + fallback.select(); + }, + }); + if (result.opened) { + qs('#review-handoff-status').textContent = 'Feedback copied. Gitea opened in a new tab.'; + } else { + directLink.hidden = false; + qs('#review-handoff-status').textContent = result.copied ? + 'Feedback copied. Your browser blocked the new tab; continue with the link below.' : + 'Clipboard unavailable. Copy the selected feedback, then continue to Gitea.'; + } + } finally { + reviewHandoffPending = false; + qs('#copy-review-feedback').disabled = false; + } + }); + + const contextPoller = createContextPoller({ + fetchContext: fetchLiveSnapshot, + onSnapshot: renderLiveSnapshot, + onError: error => { + handleContextError(error); + setEventStreamStatus('Update failed · showing last activity'); + }, + isHidden: () => document.hidden, + intervalMs: 8000, + }); + function load() { return contextPoller.refresh(); } + + const offlineStatus = qs('#offline-status'); + const keepWorkOffline = qs('#keep-work-offline'); + const offlineWorkStatus = qs('#offline-work-status'); + const deliveryReceipts = qs('#delivery-receipts'); + const deliveryReceiptStatus = qs('#delivery-receipt-status'); + function updateOfflineWorkControls(message) { + keepWorkOffline.checked = offlineWorkStore.enabled(); + const saved = offlineWorkStore.load(); + offlineWorkStatus.textContent = message || (saved ? 'Saved ' + fmt(saved.saved_at) + ' · expires after 7 days.' : + (keepWorkOffline.checked ? 'Waiting for a healthy live refresh.' : 'Off · no work data is stored.')); + } + async function updateDeliveryReceiptControls(message) { + const supported = Boolean(backgroundIssueSync && 'Notification' in window && 'serviceWorker' in navigator); + deliveryReceipts.disabled = !supported || !confirmedOwnerLogin; + if (!supported) { + deliveryReceipts.checked = false; + deliveryReceiptStatus.textContent = 'Delivery notifications are unavailable in this browser.'; + return; + } + if (!confirmedOwnerLogin) { + deliveryReceipts.checked = false; + deliveryReceiptStatus.textContent = 'Waiting for your signed-in account.'; + return; + } + deliveryReceipts.checked = Notification.permission === 'granted' && + await backgroundIssueSync.getReceiptPreference(confirmedOwnerLogin); + deliveryReceiptStatus.textContent = message || (deliveryReceipts.checked + ? 'Background delivery receipts enabled for @' + confirmedOwnerLogin + '.' + : Notification.permission === 'denied' ? 'Notifications are blocked in browser settings.' : 'Off by default.'); + } + function setOfflineWorkMode(value) { + offlineWorkMode = value; + ['#find-work', '#start-work-session', '#load-more-work', '#load-more-notifications', '#bulk-mark-read'] + .forEach(selector => { const button = qs(selector); if (button) button.disabled = value; }); + } + function hydrateOfflineWork(mode = 'offline') { + const saved = offlineWorkStore.load(); + if (!saved) return false; + const outage = mode === 'outage'; + lastNotifications = saved.notifications || []; + notificationPagination = saved.notification_pagination || { page:1, total:lastNotifications.length, has_more:false }; + workPagination = saved.work_pagination || {}; + lastContextSnapshot = saved; + hasContextSnapshot = true; + paintMyWork(saved); + setOfflineWorkMode(true); + const savedLabel = fmt(saved.saved_at); + if (outage) { + qs('#my-work-status').textContent = 'Outage · saved ' + savedLabel + ' · read-only'; + offlineStatus.textContent = 'Server unavailable · showing private My Work saved ' + savedLabel + '. Live details and actions will return automatically.'; + offlineWorkStatus.textContent = 'Outage · saved ' + savedLabel + ' · expires after 7 days.'; + setStatus('Outage · saved snapshot'); + } else { + qs('#my-work-status').textContent = 'Offline · saved ' + savedLabel + ' · read-only'; + offlineStatus.textContent = 'Offline · showing private My Work saved ' + savedLabel + '. Live details and actions require reconnection.'; + offlineWorkStatus.textContent = 'Offline · saved ' + savedLabel + ' · expires after 7 days.'; + setStatus('Offline · saved snapshot'); + } + offlineStatus.hidden = false; + return true; + } + function showOfflineStatus() { + activeFlushLogin = ''; + offlineStatus.hidden = false; + setStatus('Offline'); + if (!hasContextSnapshot) hydrateOfflineWork(); + } + function reconnectLiveData() { + offlineStatus.hidden = true; + setOfflineWorkMode(false); + setStatus('Reconnecting…'); + contextPoller.refresh(); + } + keepWorkOffline.addEventListener('change', () => { + offlineWorkStore.setEnabled(keepWorkOffline.checked); + if (keepWorkOffline.checked && liveMode && lastContextSnapshot) { + offlineWorkStore.save({ ...lastContextSnapshot, notifications:lastNotifications, + notification_pagination:notificationPagination }); + } + updateOfflineWorkControls(keepWorkOffline.checked ? 'Offline saving enabled.' : 'Offline work data cleared.'); + }); + deliveryReceipts.addEventListener('change', async () => { + let enabled = deliveryReceipts.checked; + if (enabled && Notification.permission !== 'granted') { + enabled = await Notification.requestPermission() === 'granted'; + } + deliveryReceipts.checked = enabled; + if (backgroundIssueSync && confirmedOwnerLogin) { + await backgroundIssueSync.setReceiptPreference(confirmedOwnerLogin, enabled); + } + await updateDeliveryReceiptControls(enabled ? 'Background delivery receipts enabled.' : + (Notification.permission === 'denied' ? 'Notifications are blocked in browser settings.' : 'Background delivery receipts disabled.')); + }); + qs('#clear-offline-work').addEventListener('click', () => { + offlineWorkStore.clear(); + updateOfflineWorkControls('Offline work data cleared.'); + }); + updateOfflineWorkControls(); + updateDeliveryReceiptControls(); + if (!navigator.onLine) showOfflineStatus(); + window.addEventListener('offline', showOfflineStatus); + window.addEventListener('online', reconnectLiveData); + + qs('#refresh').addEventListener('click', load); + qs('#start-work-session').addEventListener('click', () => { + const sessionItems = selectedWorkFilter === 'today' ? todayMyWork : filterMyWork(lastMyWork, selectedWorkFilter); + if (!sessionItems.length) { + qs('#my-work-action-status').textContent = 'No visible work to start.'; + return; + } + workSession.start(); + }); + document.querySelectorAll('[data-work-session-previous]').forEach(button => + button.addEventListener('click', () => workSession.previous()) + ); + document.querySelectorAll('[data-work-session-next]').forEach(button => + button.addEventListener('click', () => workSession.next()) + ); + qs('#load-more-notifications').addEventListener('click', () => + notificationPager.loadMore(lastNotifications) + ); + qs('#load-more-work').addEventListener('click', async () => { + const stream = activeWorkStreams().find(item => workPagination[item]?.has_more); + if (!stream || !lastContextSnapshot) return; + const button = qs('#load-more-work'); + button.disabled = true; + const existing = stream === 'issue' ? + (lastContextSnapshot.issues || []) : (lastContextSnapshot.pull_requests || []); + try { + const loaded = await workPager.loadMore(stream, existing); + if (loaded) document.querySelector('.my-work-card:last-child .my-work-card-main')?.focus(); + else button.focus(); + } finally { + button.disabled = false; + } + }); + qs('#bulk-mark-read').addEventListener('click', async () => { + const allIds = notificationIds(filterMyWork(lastMyWork, 'update')); + const ids = allIds.slice(0, 50); + if (!ids.length || bulkMarkPending) return; + if (!bulkConfirmationPending) { + bulkConfirmationPending = true; + qs('#my-work-action-status').textContent = 'Confirm to mark all visible updates read.'; + renderMyWork(); + return; + } + bulkConfirmationPending = false; + bulkMarkPending = true; + renderMyWork(); + const result = await bulkNotificationAcknowledger.acknowledge(lastMyWork, ids); + if (result) { + const marked = new Set(result.marked); + lastNotifications = lastNotifications.filter(item => !marked.has(item.id)); + } + bulkMarkPending = false; + renderMyWork(); + (document.querySelector('[data-notification-id]') || qs('[data-work-filter="update"]'))?.focus(); + }); + document.querySelectorAll('[data-work-filter]').forEach(button => { + button.setAttribute('aria-pressed', String(button.dataset.workFilter === selectedWorkFilter)); + button.addEventListener('click', () => { + selectedWorkFilter = button.dataset.workFilter; + try { + sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter); + } catch (e) { + console.warn('Could not persist My Work filter', e); + } + document.querySelectorAll('[data-work-filter]').forEach(item => + item.setAttribute('aria-pressed', String(item === button)) + ); + renderMyWork(); + updateWorkPaginationControls(); + }); + }); + function openDeliveryReceiptRoute() { + if (window.location.hash !== '#/my-work/drafts') return; + qs('[data-work-filter="draft"]').click(); + qs('#my-work').scrollIntoView({block:'start'}); + qs('#my-work').focus(); + } + window.addEventListener('hashchange', openDeliveryReceiptRoute); + openDeliveryReceiptRoute(); + qs('#work-milestone-filter').addEventListener('change', event => { + selectedWorkMilestone = event.target.value; + try { sessionStorage.setItem(WORK_MILESTONE_KEY, selectedWorkMilestone); } + catch (e) { console.warn('Could not persist My Work milestone lane', e); } + renderMyWork(); + if (workSession.active()) workSession.reconcile(); + }); + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('service-worker.js').catch(error => + console.warn('Stackchain install support unavailable', error) + ); + } + const isIosDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); + const isIosSafari = isIosDevice && /Safari/.test(navigator.userAgent) && + !/CriOS|FxiOS|EdgiOS|OPiOS/.test(navigator.userAgent); + createInstallApp({ + window, + card: qs('#install-app-card'), + installButton: qs('#install-app'), + dismissButton: qs('#dismiss-install-app'), + guidance: qs('#install-app-guidance'), + status: qs('#install-app-status'), + storage: localStorage, + isStandalone: () => window.matchMedia('(display-mode: standalone)').matches || + navigator.standalone === true, + isIosSafari: () => isIosSafari, + }).start(); + contextPoller.start(); + document.addEventListener('visibilitychange', () => { + contextPoller.setVisible(!document.hidden); + }); + + /* Widgets */ + function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); } + setInterval(widgetTick, 1000); +})(); diff --git a/frontend/index.html b/frontend/index.html index 02ccb1e..b1577d4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -6,294 +6,7 @@ Stackchain Dashboard - + @@ -804,2970 +517,6 @@ textarea { resize: vertical; min-height: 120px; } - + diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 4c5405a..6d174f4 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,10 +1,12 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v25'; +const CACHE = 'stackchain-dashboard-shell-v26'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const SHELL = [ BASE, BASE + 'manifest.webmanifest', + BASE + 'static/dashboard.css', + BASE + 'static/dashboard.js', BASE + 'static/icons/stackchain-192.png', BASE + 'static/icons/stackchain-512.png', BASE + 'static/session.js', diff --git a/src/main.py b/src/main.py index d390890..d80d9ba 100644 --- a/src/main.py +++ b/src/main.py @@ -539,6 +539,37 @@ async def prevent_live_api_caching(request, call_next): return response +CONTENT_SECURITY_POLICY = "; ".join( + ( + "default-src 'self'", + "script-src 'self'", + "connect-src 'self'", + "img-src 'self' data:", + "style-src 'self' 'unsafe-inline'", + "worker-src 'self'", + "manifest-src 'self'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + ) +) + + +@app.middleware("http") +async def enforce_browser_security_boundary(request: Request, call_next): + """Apply one browser trust boundary, including to auth short-circuits.""" + response = await call_next(request) + response.headers["Content-Security-Policy"] = CONTENT_SECURITY_POLICY + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["Permissions-Policy"] = ( + "camera=(), microphone=(), geolocation=(), payment=(), usb=()" + ) + response.headers["X-Frame-Options"] = "DENY" + return response + + @app.get("/healthz") def health() -> dict[str, str]: """Return process liveness without depending on Gitea.""" diff --git a/tests/dashboard_bundle.py b/tests/dashboard_bundle.py new file mode 100644 index 0000000..3b76a49 --- /dev/null +++ b/tests/dashboard_bundle.py @@ -0,0 +1,29 @@ +from pathlib import Path + +from src.views import dashboard as dashboard_html + + +FRONTEND = Path(__file__).resolve().parents[1] / "frontend" + + +def dashboard_bundle_text() -> str: + """Return the HTML, CSS, and bootstrap sources that make up the dashboard.""" + return "\n".join( + ( + (FRONTEND / "index.html").read_text(), + (FRONTEND / "dashboard.css").read_text(), + (FRONTEND / "dashboard.js").read_text(), + ) + ) + + +async def dashboard() -> str: + # Exercise the real view lookup before adding its separately served assets. + html = await dashboard_html() + return "\n".join( + ( + html, + (FRONTEND / "dashboard.css").read_text(), + (FRONTEND / "dashboard.js").read_text(), + ) + ) diff --git a/tests/test_api_paths.py b/tests/test_api_paths.py index 85f9fee..d09d461 100644 --- a/tests/test_api_paths.py +++ b/tests/test_api_paths.py @@ -2,12 +2,11 @@ import re from pathlib import Path from urllib.parse import urljoin - -DASHBOARD_HTML = Path(__file__).parent.parent / "frontend" / "index.html" +from tests.dashboard_bundle import dashboard_bundle_text def test_api_requests_resolve_inside_dashboard_subpath(): - html = DASHBOARD_HTML.read_text() + html = dashboard_bundle_text() api_paths = re.findall(r"fetch\(['\"]([^'\"]*api/v1/[^'\"]*)['\"]", html) assert api_paths diff --git a/tests/test_authored_outbox.py b/tests/test_authored_outbox.py index cb461b8..c430bb3 100644 --- a/tests/test_authored_outbox.py +++ b/tests/test_authored_outbox.py @@ -4,7 +4,7 @@ from pathlib import Path import pytest -from src.views import dashboard +from tests.dashboard_bundle import dashboard OUTBOX = Path(__file__).parents[1] / "frontend" / "authored-outbox.js" diff --git a/tests/test_background_issue_sync.py b/tests/test_background_issue_sync.py index 6da784f..b71f33f 100644 --- a/tests/test_background_issue_sync.py +++ b/tests/test_background_issue_sync.py @@ -6,7 +6,7 @@ import httpx import pytest from src import main -from src.views import dashboard +from tests.dashboard_bundle import dashboard SYNC = Path(__file__).parents[1] / "frontend" / "background-issue-sync.js" diff --git a/tests/test_command_palette.py b/tests/test_command_palette.py index 9b6c64e..5d9062e 100644 --- a/tests/test_command_palette.py +++ b/tests/test_command_palette.py @@ -4,6 +4,8 @@ from html.parser import HTMLParser from pathlib import Path from urllib.parse import urljoin +from tests.dashboard_bundle import dashboard_bundle_text + FRONTEND = Path(__file__).parents[1] / "frontend" COMMANDS = FRONTEND / "commands.js" @@ -41,7 +43,7 @@ if (action !== 'refresh') throw new Error(`expected refresh, got ${{action}}`); def test_command_script_resolves_inside_dashboard_subpath(): parser = ScriptSourceParser() - parser.feed((FRONTEND / "index.html").read_text()) + parser.feed(dashboard_bundle_text()) command_source = next(source for source in parser.sources if source.endswith("commands.js")) assert urljoin( @@ -152,7 +154,7 @@ if (commands.nextSelection(1, 'Enter', 3) !== 1) throw new Error('other keys sho def test_palette_exposes_accessible_global_work_search_under_dashboard_subpath(): - html = (FRONTEND / "index.html").read_text() + html = dashboard_bundle_text() assert 'role="combobox"' in html assert 'aria-controls="cmd-results"' in html @@ -221,7 +223,7 @@ if (!states.some(state => state.status === 'claimed')) throw new Error('claim co def test_remote_search_selection_opens_native_preview_without_navigation(): - html = (FRONTEND / "index.html").read_text() + html = dashboard_bundle_text() run_item = html.split("function runCommandItem(item)", 1)[1].split( "function renderCommands", 1 @@ -233,7 +235,7 @@ def test_remote_search_selection_opens_native_preview_without_navigation(): def test_assigned_issue_preview_hands_off_to_existing_my_work_sheet(): - html = (FRONTEND / "index.html").read_text() + html = dashboard_bundle_text() assert "detail.claimable || (detail.assigned_to_me && detail.kind === 'issue')" in html assert "claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'" in html diff --git a/tests/test_dashboard_session_frontend.py b/tests/test_dashboard_session_frontend.py index 9c27dc7..c683b98 100644 --- a/tests/test_dashboard_session_frontend.py +++ b/tests/test_dashboard_session_frontend.py @@ -4,7 +4,7 @@ from pathlib import Path import pytest -from src.views import dashboard +from tests.dashboard_bundle import dashboard ROOT = Path(__file__).resolve().parents[1] diff --git a/tests/test_drafts.py b/tests/test_drafts.py index dfb7e60..4552295 100644 --- a/tests/test_drafts.py +++ b/tests/test_drafts.py @@ -4,7 +4,7 @@ from pathlib import Path import pytest -from src.views import dashboard +from tests.dashboard_bundle import dashboard DRAFTS = Path(__file__).parents[1] / "frontend" / "drafts.js" diff --git a/tests/test_event_stream.py b/tests/test_event_stream.py index aa37730..826d0b3 100644 --- a/tests/test_event_stream.py +++ b/tests/test_event_stream.py @@ -6,13 +6,14 @@ import pytest from src import main from src import gitea_proxy +from tests.dashboard_bundle import dashboard_bundle_text DASHBOARD = Path("frontend/index.html") def test_dashboard_has_realtime_gitea_event_stream_widget(): - html = DASHBOARD.read_text() + html = dashboard_bundle_text() assert "Gitea event stream" in html assert "id=\"gitea-events\"" in html @@ -25,14 +26,14 @@ def test_dashboard_has_realtime_gitea_event_stream_widget(): def test_event_stream_reports_when_activity_was_refreshed(): - html = DASHBOARD.read_text() + html = dashboard_bundle_text() assert 'id="gitea-events-status"' in html assert "setEventStreamStatus('Updated ' + fmt(new Date()))" in html def test_event_stream_reports_refresh_failure_without_erasing_visible_events(): - html = DASHBOARD.read_text() + html = dashboard_bundle_text() assert "setEventStreamStatus('Update failed · showing last activity')" in html assert "qs('#gitea-events').innerHTML = '
Event stream unavailable.
'" not in html diff --git a/tests/test_install_app.py b/tests/test_install_app.py index 9597e66..ed8bff6 100644 --- a/tests/test_install_app.py +++ b/tests/test_install_app.py @@ -4,7 +4,7 @@ from pathlib import Path import pytest -from src.views import dashboard +from tests.dashboard_bundle import dashboard INSTALL_APP = Path(__file__).resolve().parents[1] / "frontend" / "install-app.js" diff --git a/tests/test_issue_outbox.py b/tests/test_issue_outbox.py index ea81da6..41c72dc 100644 --- a/tests/test_issue_outbox.py +++ b/tests/test_issue_outbox.py @@ -4,7 +4,7 @@ from pathlib import Path import pytest -from src.views import dashboard +from tests.dashboard_bundle import dashboard OUTBOX = Path(__file__).parents[1] / "frontend" / "issue-outbox.js" diff --git a/tests/test_mobile_task_dock.py b/tests/test_mobile_task_dock.py index c6819bf..2f6298e 100644 --- a/tests/test_mobile_task_dock.py +++ b/tests/test_mobile_task_dock.py @@ -4,7 +4,7 @@ from pathlib import Path import pytest -from src.views import dashboard +from tests.dashboard_bundle import dashboard DOCK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-task-dock.js" diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 2a48b47..c03557b 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -5,7 +5,7 @@ from pathlib import Path import pytest -from src.views import dashboard +from tests.dashboard_bundle import dashboard MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js" diff --git a/tests/test_offline_work.py b/tests/test_offline_work.py index 05bbada..2ab7d77 100644 --- a/tests/test_offline_work.py +++ b/tests/test_offline_work.py @@ -4,7 +4,7 @@ from pathlib import Path import pytest -from src.views import dashboard +from tests.dashboard_bundle import dashboard OFFLINE_WORK = Path(__file__).parents[1] / "frontend" / "offline-work.js" diff --git a/tests/test_outbox_coordinator.py b/tests/test_outbox_coordinator.py index 40610f3..25b9434 100644 --- a/tests/test_outbox_coordinator.py +++ b/tests/test_outbox_coordinator.py @@ -4,7 +4,7 @@ from pathlib import Path import pytest -from src.views import dashboard +from tests.dashboard_bundle import dashboard ROOT = Path(__file__).parents[1] diff --git a/tests/test_panel_persistence.py b/tests/test_panel_persistence.py index cde63a6..2a232c2 100644 --- a/tests/test_panel_persistence.py +++ b/tests/test_panel_persistence.py @@ -1,11 +1,13 @@ from pathlib import Path +from tests.dashboard_bundle import dashboard_bundle_text + DASHBOARD = Path("frontend/index.html") def test_dashboard_panels_are_independently_collapsible(): - html = DASHBOARD.read_text() + html = dashboard_bundle_text() for panel_key in ("context", "ai-stream", "issues", "pull-requests", "widgets", "layout", "markdown"): assert f'
None: + for name, value in REQUIRED_HEADERS.items(): + assert response.headers[name] == value + assert "camera=()" in response.headers["permissions-policy"] + policy = response.headers["content-security-policy"] + assert "default-src 'self'" in policy + assert "script-src 'self'" in policy + assert "object-src 'none'" in policy + assert "frame-ancestors 'none'" in policy + script_policy = next(part for part in policy.split(";") if "script-src" in part) + assert "'unsafe-inline'" not in script_policy + assert "'unsafe-eval'" not in script_policy + + +@pytest.mark.anyio +async def test_security_boundary_covers_pages_health_static_and_api_errors(monkeypatch): + monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "access-token-with-at-least-thirty-two-characters") + monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "session-secret-with-at-least-thirty-two-characters") + monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator") + transport = httpx.ASGITransport(app=main.app) + + async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: + responses = [ + await client.get("/healthz"), + await client.get("/login"), + await client.get("/static/dashboard.js"), + await client.get("/api/v1/context"), + ] + + assert [response.status_code for response in responses] == [200, 200, 200, 401] + for response in responses: + assert_browser_security_boundary(response) + + +@pytest.mark.anyio +async def test_security_boundary_covers_fail_closed_authentication_response(monkeypatch): + monkeypatch.delenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", raising=False) + monkeypatch.delenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", raising=False) + monkeypatch.delenv("STACKCHAIN_DASHBOARD_AUTH_MODE", raising=False) + transport = httpx.ASGITransport(app=main.app) + + async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: + response = await client.get("/login") + + assert response.status_code == 503 + assert_browser_security_boundary(response) diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 3f5bb53..be31146 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -92,10 +92,12 @@ async function dispatchNotificationClick(route) {{ return json.loads(completed.stdout) -def test_mobile_install_flow_ships_in_a_new_shell_cache(): +def test_strict_browser_assets_ship_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v25" in source + assert "stackchain-dashboard-shell-v26" in source + assert "BASE + 'static/dashboard.css'" in source + assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -196,6 +198,8 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): assert set(result["added"]) == { "/dashboard/", "/dashboard/manifest.webmanifest", + "/dashboard/static/dashboard.css", + "/dashboard/static/dashboard.js", "/dashboard/static/icons/stackchain-192.png", "/dashboard/static/icons/stackchain-512.png", "/dashboard/static/session.js", diff --git a/tests/test_shared_context_snapshot.py b/tests/test_shared_context_snapshot.py index 1acf70c..ba46ead 100644 --- a/tests/test_shared_context_snapshot.py +++ b/tests/test_shared_context_snapshot.py @@ -1,6 +1,6 @@ import pytest -from src.views import dashboard +from tests.dashboard_bundle import dashboard @pytest.mark.anyio diff --git a/tests/test_today_work.py b/tests/test_today_work.py index a4c882f..3433b09 100644 --- a/tests/test_today_work.py +++ b/tests/test_today_work.py @@ -4,7 +4,7 @@ from pathlib import Path import pytest -from src.views import dashboard +from tests.dashboard_bundle import dashboard TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js" diff --git a/tests/test_views.py b/tests/test_views.py index e83aa4e..77cdecc 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,3 +1,5 @@ +from pathlib import Path + import pytest from src.views import dashboard @@ -21,15 +23,26 @@ async def test_dashboard_viewport_allows_mobile_zoom(): assert "user-scalable=no" not in html +@pytest.mark.anyio +async def test_dashboard_bootstrap_uses_csp_compatible_static_assets(): + html = await dashboard() + + assert '' in html + assert '' in html + assert "