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 => '').join('') : 'No open issues.
');
+
+ const prsBox = qs('#prs-content');
+ const openPrs = (data.pull_requests || []).slice(0, 12);
+ prsBox.innerHTML = (openPrs.length ? openPrs.map(p => '').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 '';
+ }
+
+ 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 =>
+ ''
+ ).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; }
-
+