stackchain-dashboard/frontend/index.html
timmy 58376f6d45
All checks were successful
CI / lint (pull_request) Successful in 12s
CI / build-frontend (pull_request) Successful in 4s
feat: add inline mobile review comments (#165)
2026-08-07 04:28:44 +00:00

1706 lines
85 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Stackchain Dashboard</title>
<style>
: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; }
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; cursor: pointer; border-radius: 10px; color:#e5e7eb; }
.cmd-item:hover { background: #10233a; }
#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; }
.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; }
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
.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; }
.mark-update-read { min-height:44px; width:100%; }
.read-update { min-height:44px; width:100%; }
.load-more-notifications { min-height:44px; width:100%; margin-top:10px; }
.load-more-notifications[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-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-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-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-retry { min-height:44px; width:100%; margin-top:10px; }
.new-issue { min-height:44px; }
.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 { 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; }
.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; }
.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; }
@media (max-width: 600px) {
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; }
.create-issue-panel { width:100%; border-left:0; padding:14px; }
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
}
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') 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); } }
</style>
</head>
<body>
<canvas id="bg"></canvas>
<header>
<div><span class="obi"></span> <strong style="margin-left:6px;">Stackchain</strong> <span class="muted">Dashboard</span></div>
<div class="toolbar">
<div class="status"><span class="dot"></span><span class="small" id="status">Live</span></div>
<button id="refresh">Refresh</button>
<button id="open-palette">Command</button>
<span class="small muted" id="clock"></span>
</div>
</header>
<main>
<section class="panel my-work" id="my-work">
<div class="my-work-header">
<div>
<h2>My Work</h2>
<div class="small" id="my-work-status" aria-live="polite">Loading assigned work…</div>
</div>
<button class="new-issue" id="new-issue" type="button">New issue</button>
<div class="work-filters" aria-label="Filter My Work">
<button class="work-filter" data-work-filter="all" aria-pressed="true">All <span data-work-count="all">0</span></button>
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
<button class="work-filter" data-work-filter="update" aria-pressed="false">Updates <span data-work-count="update">0</span></button>
</div>
</div>
<div class="my-work-list" id="my-work-list"></div>
<div class="small" id="notification-page-status" aria-live="polite"></div>
<button class="load-more-notifications" id="load-more-notifications" type="button" hidden>Load older updates</button>
<div class="small" id="my-work-action-status" aria-live="assertive"></div>
<div class="my-work-bulk" id="bulk-mark-read-bar" hidden>
<button id="bulk-mark-read" type="button"></button>
</div>
</section>
<aside class="sidebar">
<details class="panel stack" data-panel-key="context" open>
<summary><h2>Context &amp; view</h2></summary>
<div>
<h2>Context</h2>
<div class="muted" id="context">Loading…</div>
</div>
<div>
<h2>View</h2>
<div class="muted" id="view-hint"></div>
</div>
</details>
<details class="panel stack" data-panel-key="ai-stream" open>
<summary><h2>AI Stream</h2></summary>
<div id="ai" class="muted">Loading…</div>
</details>
</aside>
<section class="stack" id="work">
<details class="panel" id="issues" data-panel-key="issues" open>
<summary><h2>Issues</h2></summary>
<div id="issues-content" class="muted">Loading…</div>
</details>
<details class="panel" id="prs" data-panel-key="pull-requests" open>
<summary><h2>Pull Requests</h2></summary>
<div id="prs-content" class="muted">Loading…</div>
</details>
<details class="panel" data-panel-key="gitea-events" open>
<summary><h2>Gitea event stream</h2></summary>
<div id="gitea-events" class="muted" aria-live="polite">Loading…</div>
<div id="gitea-events-status" class="small" aria-live="polite">Waiting for activity…</div>
</details>
<details class="panel" data-panel-key="widgets" open>
<summary><h2>Live Widgets</h2></summary>
<div id="widgets" class="stack">
<div class="widget">
<h3>Live clock</h3>
<div class="small" id="widget-clock"></div>
</div>
<div class="widget">
<h3>Repo mix</h3>
<div class="small" id="repo-mix">Loading…</div>
</div>
</div>
</details>
</section>
<aside class="sidebar">
<details class="panel stack" data-panel-key="layout" open>
<summary><h2>Layout</h2></summary>
<div class="muted" id="layout-hint"></div>
</details>
<details class="panel stack" data-panel-key="markdown" open>
<summary><h2>Markdown</h2></summary>
<textarea id="md-input" placeholder="# Write markdown here...
- bullet
- **bold**
- `code`"></textarea>
<div id="md-preview" style="margin-top:8px;"></div>
</details>
</aside>
</main>
<div id="cmd-palette" role="dialog" aria-label="Command palette">
<input id="cmd-input" type="text" placeholder="Type a command, e.g. whiteboard, markdown..." />
<div id="cmd-results" class="stack" style="margin-top:8px;"></div>
</div>
<div id="whiteboard-modal">
<div class="modal">
<div class="modal-actions">
<strong>Whiteboard</strong>
<div>
<span class="badge">Click & drag to draw</span>
<button id="close-whiteboard">Close</button>
</div>
</div>
<canvas id="wb"></canvas>
<div class="row" style="margin-top:8px;">
<button id="wb-clear">Clear</button>
<button id="wb-save">Save PNG</button>
</div>
</div>
</div>
<div class="issue-sheet" id="issue-sheet" role="dialog" aria-modal="true" aria-labelledby="issue-sheet-title">
<section class="issue-sheet-panel">
<div class="issue-sheet-header">
<div>
<div class="small" id="issue-sheet-key"></div>
<h3 id="issue-sheet-title">Assigned issue</h3>
</div>
<button id="close-issue-sheet" type="button">Close sheet</button>
</div>
<div id="issue-sheet-status" class="small" aria-live="polite">Choose an issue.</div>
<button class="issue-retry" id="retry-issue-load" type="button" hidden>Retry loading issue</button>
<div class="row"><span id="issue-labels"></span><span class="small" id="issue-assignees"></span></div>
<p class="issue-sheet-content" id="issue-sheet-body"></p>
<h2>Recent discussion</h2>
<div id="issue-comments"></div>
<section class="issue-comment-composer" aria-labelledby="issue-comment-title">
<h2 id="issue-comment-title">Add comment</h2>
<textarea id="issue-comment" maxlength="10000" placeholder="Write a comment"></textarea>
<button id="send-issue-comment" type="button">Post comment</button>
<div id="issue-comment-status" class="small" aria-live="assertive"></div>
</section>
<div class="issue-sheet-actions">
<button id="close-issue" type="button">Close issue</button>
<a id="open-issue-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
</div>
</section>
</div>
<div class="create-issue-sheet" id="create-issue-sheet" role="dialog" aria-modal="true" aria-labelledby="create-issue-heading">
<section class="create-issue-panel">
<div class="create-issue-header">
<h3 id="create-issue-heading">New issue</h3>
<button id="cancel-new-issue" type="button">Cancel</button>
</div>
<form class="create-issue-form" id="create-issue-form">
<label for="create-issue-repository">Repository
<select id="create-issue-repository" required></select>
</label>
<label for="create-issue-title">Title
<input id="create-issue-title" type="text" maxlength="255" required autocomplete="off" />
</label>
<label for="create-issue-body">Description <span class="small">Optional</span>
<textarea id="create-issue-body" maxlength="10000"></textarea>
</label>
<fieldset class="create-issue-labels" id="create-issue-labels" aria-describedby="create-issue-label-status">
<legend>Labels <span class="small">Optional</span></legend>
<div class="small" id="create-issue-label-status" aria-live="polite">Choose a repository to load labels.</div>
<div class="create-issue-label-list" id="create-issue-label-list"></div>
</fieldset>
<div class="small">The issue will be assigned to you.</div>
<div class="create-issue-actions">
<button id="submit-new-issue" type="submit">Create &amp; assign to me</button>
<div id="create-issue-status" class="small" aria-live="assertive"></div>
</div>
</form>
</section>
</div>
<div class="update-sheet" id="update-sheet" role="dialog" aria-modal="true" aria-labelledby="update-sheet-title">
<section class="update-sheet-panel">
<div class="update-sheet-header">
<div>
<div class="small" id="update-sheet-key"></div>
<h3 id="update-sheet-title">Unread update</h3>
</div>
<button id="keep-update-unread" type="button">Keep unread</button>
</div>
<div id="update-sheet-status" class="small" aria-live="polite">Choose an update.</div>
<button class="update-retry" id="retry-update-load" type="button" hidden>Retry loading update</button>
<div class="row">
<span class="pill" id="update-subject-type">Update</span>
<span class="pill" id="update-subject-state"></span>
</div>
<h2>Latest comment</h2>
<div class="small" id="update-comment-meta"></div>
<p class="update-sheet-content" id="update-comment-body"></p>
<details>
<summary><h2>Subject context</h2></summary>
<p class="update-sheet-content muted" id="update-subject-body"></p>
</details>
<section class="update-reply" aria-labelledby="update-reply-title">
<h2 id="update-reply-title">Reply</h2>
<label for="update-reply" class="small">Continue this issue or pull-request conversation</label>
<textarea id="update-reply" maxlength="10000" placeholder="Write a reply"></textarea>
<button id="send-update-reply" type="button">Send reply</button>
<div id="update-reply-status" class="small" aria-live="assertive"></div>
</section>
<div class="update-sheet-actions">
<button id="mark-update-read-next" type="button">Mark read &amp; next</button>
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
</div>
</section>
</div>
<div class="pull-sheet" id="pull-sheet" role="dialog" aria-modal="true" aria-labelledby="pull-sheet-title">
<section class="pull-sheet-panel">
<div class="pull-sheet-header">
<div><div class="small" id="pull-sheet-key"></div><h3 id="pull-sheet-title">Assigned pull request</h3></div>
<button id="close-pull-sheet" type="button">Close</button>
</div>
<div id="pull-sheet-status" class="small" aria-live="polite">Choose a pull request.</div>
<button class="pull-retry" id="retry-pull-load" type="button" hidden>Retry loading pull request</button>
<div class="row"><span class="pill" id="pull-ci-state">CI unknown</span><span class="pill" id="pull-merge-state">Checking merge status</span></div>
<p class="pull-sheet-content" id="pull-sheet-body"></p>
<h2>Changed files</h2><div id="pull-files"></div>
<h2>Recent discussion</h2><div id="pull-comments"></div>
<section class="pull-comment-composer" aria-labelledby="pull-comment-title">
<h2 id="pull-comment-title">Add comment</h2>
<textarea id="pull-comment" maxlength="10000" placeholder="Write a comment"></textarea>
<button id="send-pull-comment" type="button">Post comment</button>
<div id="pull-comment-status" class="small" aria-live="assertive"></div>
</section>
<div class="pull-sheet-actions">
<button id="merge-pull" type="button" disabled>Merge</button>
<a id="open-pull-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
</div>
</section>
</div>
<div class="review-sheet" id="review-sheet" role="dialog" aria-modal="true" aria-labelledby="review-sheet-title">
<section class="review-sheet-panel">
<div class="review-sheet-header">
<div>
<div class="small" id="review-sheet-key"></div>
<h3 id="review-sheet-title">Pull request review</h3>
</div>
<button class="review-action" id="close-review-sheet">Close</button>
</div>
<div id="review-sheet-status" class="small" aria-live="polite">Choose a review request.</div>
<button class="review-retry" id="retry-review-load" hidden>Retry loading review</button>
<p class="review-sheet-body" id="review-sheet-body"></p>
<div class="row"><span class="pill" id="review-ci-state">CI unknown</span><a id="open-review-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a></div>
<h2>Changed files</h2>
<div id="review-files" class="muted"></div>
<section class="review-inline-composer" id="review-inline-composer" aria-labelledby="review-inline-title" hidden>
<strong id="review-inline-title">Comment on changed line</strong>
<label class="small" id="review-inline-location" for="review-inline-body"></label>
<textarea id="review-inline-body" maxlength="10000" placeholder="Write a line-specific comment"></textarea>
<div class="review-inline-actions">
<button id="save-inline-comment" type="button">Save draft comment</button>
<button id="delete-inline-comment" type="button">Delete draft</button>
<button id="cancel-inline-comment" type="button">Cancel</button>
</div>
</section>
<div class="review-progress-actions">
<span class="small" id="review-progress" aria-live="polite">0 of 0 files reviewed</span>
<button id="next-unreviewed-review" disabled>Next unreviewed</button>
</div>
<section class="review-feedback" aria-labelledby="review-feedback-title">
<h2 id="review-feedback-title">Draft feedback</h2>
<label>Intended decision
<select id="review-decision">
<option value="comment">Comment</option>
<option value="approve">Approve</option>
<option value="request_changes">Request changes</option>
</select>
</label>
<label>Overall summary
<textarea id="review-summary" placeholder="Summarize your review"></textarea>
</label>
<div class="review-handoff">
<button id="submit-review">Submit review</button>
<span class="small" id="review-submit-status" aria-live="assertive"></span>
<button id="copy-review-feedback">Copy feedback &amp; continue in Gitea</button>
<span class="small" id="review-handoff-status" aria-live="polite"></span>
<textarea class="review-copy-fallback" id="review-copy-fallback" readonly hidden aria-label="Feedback to copy manually"></textarea>
<a class="review-handoff-link" id="review-handoff-link" target="_blank" rel="noopener noreferrer" hidden>Continue to Gitea</a>
</div>
</section>
<h2>Review history</h2>
<div id="review-history" class="muted"></div>
</section>
</div>
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
<script src="static/markdown.js"></script>
<script src="static/commands.js"></script>
<script src="static/widgets.js"></script>
<script src="static/my-work.js"></script>
<script src="static/issue-sheet.js"></script>
<script src="static/create-issue-sheet.js"></script>
<script src="static/pull-sheet.js"></script>
<script src="static/review-sheet.js"></script>
<script src="static/context-poller.js"></script>
<script>
(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);
}
});
});
let liveMode = true;
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
let selectedWorkFilter = 'all';
try {
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
if (['all', 'issue', 'pull', 'review', 'update'].includes(savedFilter)) selectedWorkFilter = savedFilter;
} catch (e) {
console.warn('Could not restore My Work filter', e);
}
let lastMyWork = [];
let lastNotifications = [];
let lastContextSnapshot = null;
let notificationPagination = { page: 1, total: 0, has_more: false };
let hasContextSnapshot = false;
let selectedReview = null;
let reviewTrigger = null;
let selectedUpdate = null;
let updateTrigger = null;
let selectedIssue = null;
let issueTrigger = null;
let selectedPull = null;
let pullTrigger = null;
let selectedPullDetail = null;
let creatingIssue = false;
let progress = null;
let draft = null;
let reviewFiles = [];
let selectedReviewHead = '';
let activeInlineTarget = null;
let bulkConfirmationPending = false;
let bulkMarkPending = false;
let reviewHandoffPending = false;
async function fetchReviewJson(url, options) {
const response = await fetch(url, options);
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Review request failed.');
return payload;
}
const reviewController = createReviewController({ fetchJson: fetchReviewJson });
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
function setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
function setClock() { qs('#clock').textContent = fmt(new Date()); }
setClock(); setInterval(setClock, 1000);
async function fetchLiveSnapshot() {
const res = await fetch('api/v1/live', { 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 postNotificationReply(notificationId, body) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/reply', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
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 notificationReplier = createNotificationReplier({
post: postNotificationReply,
storage: localStorage,
onStatus: message => { qs('#update-reply-status').textContent = message; },
});
const notificationReader = createNotificationReader({
load: fetchNotificationDetail,
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-comment-meta').textContent = '';
qs('#update-comment-body').textContent = '';
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 => {
const comment = detail.latest_comment || {};
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-comment-meta').textContent = comment.author ?
comment.author + (comment.created_at ? ' · ' + fmt(comment.created_at) : '') :
(comment.created_at ? fmt(comment.created_at) : 'No comment author reported');
qs('#update-comment-body').textContent = comment.body || 'No comment body was provided.';
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;
},
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 renderContextSnapshot(data) {
liveMode = true;
hasContextSnapshot = true;
lastContextSnapshot = data;
if (data.error && lastMyWork.length) markMyWorkStale();
else paintMyWork(data);
qs('#context').innerHTML = '<div class="kv"><div class="label">User</div><div class="value">' + escapeHtml(data.user?.full_name || data.user?.login || '—') + '</div>' +
'<div class="label">Repos</div><div class="value">' + (data.repos?.length || 0) + '</div>' +
'<div class="label">Issues</div><div class="value">' + (data.issues?.length || 0) + '</div>' +
'<div class="label">PRs</div><div class="value">' + (data.pull_requests?.length || 0) + '</div></div>';
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 => '<div style="margin:6px 0;"><a href="' + escAttr(i.url) + '" target="_blank">#' + i.number + ' ' + escapeHtml(i.title) + '</a>' +
'<div class="muted">' + (i.labels || []).map(l => '<span class="pill">' + escapeHtml(String(l)) + '</span>').join(' ') + '</div></div>').join('') : '<div class="muted">No open issues.</div>');
const prsBox = qs('#prs-content');
const openPrs = (data.pull_requests || []).slice(0, 12);
prsBox.innerHTML = (openPrs.length ? openPrs.map(p => '<div style="margin:6px 0;"><a href="' + escAttr(p.url) + '" target="_blank">#' + p.number + ' ' + escapeHtml(p.title) + '</a>' +
'<div class="muted">' + escapeHtml(p.state) + ' by ' + escapeHtml(String(p.user || '')) + '</div></div>').join('') : '<div class="muted">No PRs.</div>');
paintDeltas(data.deltas || []);
qs('#layout-hint').innerHTML = '<div class="kv"><div class="label">Active view</div><div class="value">' + escapeHtml(data.view || 'dashboard') + '</div><div class="label">Deltas</div><div class="value">' + (data.deltas||[]).length + '</div></div>';
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;
setStatus(hasContextSnapshot ? 'Update failed · showing last snapshot' : 'Unavailable');
if (!hasContextSnapshot) {
qs('#context').innerHTML = '<div class="muted">Context unavailable.</div>';
qs('#view-hint').textContent = 'Active view unavailable.';
qs('#issues-content').innerHTML = '<div class="muted">Work items unavailable.</div>';
qs('#prs-content').innerHTML = '<div class="muted">Work items unavailable.</div>';
paintDeltas([]);
}
markMyWorkStale();
}
function paintMyWork(data) {
lastMyWork = buildMyWork(data);
refreshMyWorkView();
}
function refreshMyWorkView() {
const counts = countMyWork(lastMyWork);
Object.entries(counts).forEach(([filter, count]) => {
const element = qs('[data-work-count="' + filter + '"]');
if (element) element.textContent = count;
});
qs('#my-work').removeAttribute('data-stale');
qs('#my-work-status').textContent = lastMyWork.length ?
summarizeMyWork(lastMyWork) : 'No assigned work, review requests, or unread updates.';
renderMyWork();
}
function renderMyWork() {
const visible = filterMyWork(lastMyWork, selectedWorkFilter);
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
const contents =
'<span class="small">' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : (item.kind === 'update' ? 'Update' : 'Issue')) + '</span>' +
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
'<span class="pill">' + escapeHtml(item.reason) + '</span>' +
(item.has_update ? ' <span class="pill">Unread update</span>' : '') +
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
const markRead = item.has_update && Number.isInteger(item.notification_id) ?
'<button class="mark-update-read" data-notification-id="' + item.notification_id + '">Mark read</button>' : '';
const readUpdate = item.has_update && Number.isInteger(item.notification_id) ?
'<button class="read-update" data-update-index="' + index + '">Read update</button>' : '';
if (item.is_review) {
return '<article class="my-work-card"><button class="my-work-card-main review-trigger" data-review-index="' + index + '">' + contents + '</button>' + readUpdate + markRead + '</article>';
}
if (item.kind === 'issue') {
return '<article class="my-work-card"><button class="my-work-card-main issue-trigger" data-issue-index="' + index + '">' + contents + '</button>' + readUpdate + markRead + '</article>';
}
if (item.kind === 'pull') {
return '<article class="my-work-card"><button class="my-work-card-main pull-trigger" data-pull-index="' + index + '">' + contents + '</button>' + readUpdate + markRead + '</article>';
}
return '<article class="my-work-card"><a class="my-work-card-main" href="' + escAttr(item.url) + '" target="_blank" rel="noopener noreferrer">' + contents + '</a>' + readUpdate + markRead + '</article>';
}).join('') : '<div class="muted">No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))) + '.</div>';
document.querySelectorAll('[data-review-index]').forEach(button => {
button.addEventListener('click', () => openReviewSheet(lastMyWork[Number(button.dataset.reviewIndex)], button));
});
document.querySelectorAll('[data-issue-index]').forEach(button => {
button.addEventListener('click', () => openIssueSheet(lastMyWork[Number(button.dataset.issueIndex)], button));
});
document.querySelectorAll('[data-pull-index]').forEach(button => {
button.addEventListener('click', () => openPullSheet(lastMyWork[Number(button.dataset.pullIndex)], button));
});
document.querySelectorAll('[data-update-index]').forEach(button => {
button.addEventListener('click', () => {
const item = lastMyWork[Number(button.dataset.updateIndex)];
if (!item) return;
updateTrigger = button;
notificationReader.open(item, lastMyWork);
});
});
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();
}
});
});
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';
}
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 '<div class="issue-comment"><div class="small">' +
escapeHtml(comment.author || 'Unknown author') +
(comment.created_at ? ' · ' + escapeHtml(fmt(comment.created_at)) : '') +
'</div><div class="issue-sheet-content">' +
escapeHtml(comment.body || 'No comment body provided.') + '</div></div>';
}
async function openIssueSheet(item, trigger) {
if (!item) return;
selectedIssue = item;
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('#issue-comment').value = issueController.loadDraft(item);
qs('#issue-comment-status').textContent = '';
qs('#retry-issue-load').hidden = true;
qs('#open-issue-gitea').href = item.url || '#';
qs('#send-issue-comment').disabled = false;
qs('#close-issue').disabled = false;
qs('#close-issue-sheet').focus();
try {
const detail = await issueController.load(item);
if (selectedIssue !== item) return;
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 =>
'<span class="pill">' + escapeHtml(label) + '</span>'
).join(' ');
qs('#issue-assignees').textContent = (detail.assignees || []).length ?
'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported';
qs('#issue-comments').innerHTML = (detail.comments || []).length ?
detail.comments.map(renderIssueComment).join('') : '<div class="muted">No comments yet.</div>';
qs('#open-issue-gitea').href = detail.url || item.url || '#';
qs('#issue-sheet-status').textContent = 'Issue ready · ' + (detail.state || 'open');
} 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() {
qs('#issue-sheet').classList.remove('open');
selectedIssue = null;
if (issueTrigger?.isConnected) issueTrigger.focus();
}
async function openPullSheet(item, trigger) {
if (!item) return;
selectedPull = item;
pullTrigger = trigger;
selectedPullDetail = 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-comments').textContent = '';
qs('#pull-comment').value = pullController.loadDraft(item);
qs('#pull-comment-status').textContent = '';
qs('#pull-ci-state').textContent = 'CI unknown';
qs('#pull-merge-state').textContent = 'Checking merge status';
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;
const eligibility = createPullSheet.mergeEligibility(detail);
qs('#pull-sheet-title').textContent = detail.title || 'Assigned pull request';
qs('#pull-sheet-body').textContent = detail.body || 'No description provided.';
qs('#pull-ci-state').textContent = 'CI ' + (detail.ci_state || 'unknown');
qs('#pull-merge-state').textContent = eligibility.reason;
qs('#merge-pull').disabled = !eligibility.allowed;
qs('#pull-files').innerHTML = (detail.files || []).length ? detail.files.map(file =>
'<div class="pull-file"><strong>' + escapeHtml(file.filename) + '</strong><div class="small">' +
escapeHtml(file.status || 'changed') + ' · +' + Number(file.additions || 0) + ' / ' + Number(file.deletions || 0) + '</div></div>'
).join('') : '<div class="muted">No changed files reported.</div>';
qs('#pull-comments').innerHTML = (detail.comments || []).length ? detail.comments.map(comment =>
'<div class="pull-comment-card">' + renderIssueComment(comment) + '</div>'
).join('') : '<div class="muted">No comments yet.</div>';
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() {
qs('#pull-sheet').classList.remove('open');
selectedPull = null;
selectedPullDetail = 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 saveIssueCaptureDraft() {
issueCapture.saveDraft({
repository: qs('#create-issue-repository').value,
title: qs('#create-issue-title').value,
body: qs('#create-issue-body').value,
labelIds: selectedIssueLabelIds(),
});
}
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 =>
'<label class="create-issue-label-option"><input type="checkbox" name="create-issue-label" value="' +
Number(label.id) + '"' + (selected.has(Number(label.id)) ? ' checked' : '') + '><span>' +
escapeHtml(label.name) + '</span></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.';
}
}
function openCreateIssueSheet() {
const captureDraft = issueCapture.loadDraft();
const repositories = lastContextSnapshot?.repos || [];
qs('#create-issue-repository').innerHTML = repositories.map(repository =>
'<option value="' + escAttr(repository.full_name) + '">' + escapeHtml(repository.full_name) + '</option>'
).join('');
if (repositories.some(repository => repository.full_name === captureDraft.repository)) {
qs('#create-issue-repository').value = captureDraft.repository;
}
qs('#create-issue-title').value = captureDraft.title;
qs('#create-issue-body').value = captureDraft.body;
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
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 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('#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('') : '<div>No changed files reported.</div>';
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 =>
'<div class="review-history"><strong>' + escapeHtml(review.user?.login || 'Reviewer') + '</strong> · ' +
escapeHtml(review.state || 'commented') + (review.body ? '<div class="small">' + escapeHtml(review.body) + '</div>' : '') + '</div>'
).join('') : '<div>No prior reviews.</div>';
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() {
qs('#review-sheet').classList.remove('open');
selectedReview = null;
progress = null;
draft = null;
reviewFiles = [];
selectedReviewHead = '';
closeInlineComposer();
if (reviewTrigger?.isConnected) reviewTrigger.focus();
}
function closeUpdateSheet(restoreTrigger = true) {
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 => '<div class="suggestion ' + d.priority + '"><span class="pill">' + escapeHtml(d.priority) + '</span> <strong>' + escapeHtml(d.action) + '</strong> ' + escapeHtml(d.target || '') + '<div class="muted">' + escapeHtml(d.panel) + '</div></div>').join('') : '<div class="muted">No suggestions yet.</div>';
}
function paintEventStream(events) {
const el = qs('#gitea-events');
el.innerHTML = events.length ? events.slice(0, 12).map(event =>
'<div class="event"><strong>' + escapeHtml(event.actor?.login || 'Gitea') + '</strong> ' +
escapeHtml(event.type || 'activity') +
'<div class="small">' + escapeHtml(event.repo?.full_name || '') +
(event.created_at ? ' · ' + escapeHtml(fmt(event.created_at)) : '') + '</div></div>'
).join('') : '<div class="muted">No recent Gitea activity.</div>';
}
function setEventStreamStatus(message) {
qs('#gitea-events-status').textContent = message;
}
function renderLiveSnapshot(snapshot) {
const notificationsFresh = Array.isArray(snapshot.notifications);
if (notificationsFresh) {
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) {
snapshot.context.notifications = lastNotifications;
renderContextSnapshot(snapshot.context);
if (!notificationsFresh) markNotificationsStale();
} else handleContextError(new Error('Context section unavailable'));
if (snapshot.events) {
paintEventStream(snapshot.events);
setEventStreamStatus('Updated ' + fmt(new Date()));
} else {
setEventStreamStatus('Update failed · showing last activity');
}
if (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 (snapshot.freshness?.revalidating) {
setEventStreamStatus('Refreshing · showing recent snapshot');
}
}
function escapeHtml(s) { return String(s || '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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<w;x+=step){ ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,h); ctx.stroke(); }
for(let y=0;y<h;y+=step){ ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke(); }
const cx=w/2+Math.sin(time*0.31)*90, cy=h/2+Math.cos(time*0.37)*70;
const grad=ctx.createRadialGradient(cx,cy,20,cx,cy,260);
grad.addColorStop(0,'rgba(96,165,250,.28)'); grad.addColorStop(1,'rgba(96,165,250,0)');
ctx.fillStyle=grad; ctx.fillRect(0,0,w,h);
requestAnimationFrame(draw);
};
draw();
})();
/* Whiteboard */
function initWhiteboard() {
const canvas = qs('#wb'), ctx = canvas.getContext('2d');
let drawing = false;
function resize() { const dpr = window.devicePixelRatio||1; const r=canvas.getBoundingClientRect(); canvas.width=r.width*dpr; canvas.height=r.height*dpr; ctx.setTransform(dpr,0,0,dpr,0,0); ctx.lineCap='round'; ctx.lineJoin='round'; ctx.strokeStyle='#e5e7eb'; ctx.lineWidth=2; }
resize(); addEventListener('resize', resize);
const p = (e) => ({ 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 = '<pre>' + escapeHtml(raw) + '</pre><div style="margin-top:8px;">' + renderMarkdown(raw) + '</div>';
}
/* 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' }) },
];
function renderCommands(filter) {
const el = qs('#cmd-results');
const items = filterCommands(commands, filter);
el.innerHTML = items.map((c, idx) => '<div class="cmd-item" data-idx="' + idx + '">' + escapeHtml(c.name) + '</div>').join('');
el.querySelectorAll('.cmd-item').forEach((item) => {
item.addEventListener('click', () => { items[Number(item.dataset.idx)].run(); qs('#cmd-palette').classList.remove('open'); qs('#cmd-input').value=''; });
});
}
qs('#open-palette').addEventListener('click', () => { qs('#cmd-palette').classList.add('open'); qs('#cmd-input').focus(); renderCommands(''); });
qs('#cmd-input').addEventListener('input', (e) => renderCommands(e.target.value));
document.addEventListener('keydown', (e) => {
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.metaKey||e.ctrlKey) && e.key==='k') {
e.preventDefault();
qs('#cmd-palette').classList.toggle('open');
if (qs('#cmd-palette').classList.contains('open')) {
qs('#cmd-input').focus();
renderCommands('');
}
}
});
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
qs('#cancel-new-issue').addEventListener('click', () => {
saveIssueCaptureDraft();
closeCreateIssueSheet();
});
['#create-issue-title', '#create-issue-body'].forEach(selector =>
qs(selector).addEventListener('input', saveIssueCaptureDraft)
);
qs('#create-issue-repository').addEventListener('change', event => {
loadIssueLabels(event.target.value);
saveIssueCaptureDraft();
});
qs('#create-issue-label-list').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(),
};
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 = 'Creating issue…';
try {
const confirmed = await issueCapture.submit(captureDraft);
lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
lastMyWork = buildMyWork(lastContextSnapshot);
const created = lastMyWork.find(item =>
item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number
);
closeCreateIssueSheet();
refreshMyWorkView();
qs('#my-work-action-status').textContent = created.key + ' created and assigned to you.';
openIssueSheet(created, qs('#new-issue'));
} 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-comment').addEventListener('input', event => {
if (selectedIssue) issueController.saveDraft(selectedIssue, event.target.value);
});
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);
const empty = qs('#issue-comments .muted');
if (empty) empty.remove();
qs('#issue-comments').insertAdjacentHTML('beforeend', renderIssueComment(comment));
qs('#issue-comment').value = '';
qs('#issue-comment-status').textContent = 'Comment posted.';
} catch (error) {
qs('#issue-comment-status').textContent = error.message + ' Your draft is safe; retry.';
qs('#issue-comment').focus();
} finally {
button.disabled = false;
}
});
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);
lastMyWork = lastMyWork.filter(item =>
!(item.kind === 'issue' && item.repository === closing.repository && item.number === closing.number)
);
closeIssueSheet();
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-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);
qs('#pull-comments .muted')?.remove();
qs('#pull-comments').insertAdjacentHTML('beforeend', '<div class="pull-comment-card">' + renderIssueComment(comment) + '</div>');
qs('#pull-comment').value = '';
qs('#pull-comment-status').textContent = 'Comment posted.';
} catch (error) {
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);
lastMyWork = lastMyWork.filter(item =>
!(item.kind === 'pull' && item.repository === merging.repository && item.number === merging.number)
);
closePullSheet();
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('#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) {
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 result = await reviewController.submit(selectedReview, {
decision: snapshot.decision,
body: createReviewController.formatFeedback(snapshot, reviewFiles),
expected_head_sha: selectedReviewHead,
comments: snapshot.comments,
});
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') + '.';
await load();
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('#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(); }
qs('#refresh').addEventListener('click', load);
qs('#load-more-notifications').addEventListener('click', () =>
notificationPager.loadMore(lastNotifications)
);
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();
});
});
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);
})();
</script>
</body>
</html>