feat: persist collapsible dashboard panels
Some checks failed
CI / build-frontend (pull_request) Has been skipped
CI / lint (pull_request) Failing after 7s

This commit is contained in:
timmy 2026-08-04 14:53:06 +00:00
parent 8a406b6254
commit 6bfc97dcdf
2 changed files with 75 additions and 28 deletions

View File

@ -14,6 +14,9 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
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; } }
@ -68,7 +71,8 @@ textarea { resize: vertical; min-height: 120px; }
<main>
<aside class="sidebar">
<div class="panel stack">
<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>
@ -77,41 +81,41 @@ textarea { resize: vertical; min-height: 120px; }
<h2>View</h2>
<div class="muted" id="view-hint"></div>
</div>
</div>
<div class="panel stack">
<h2>AI Stream</h2>
</details>
<details class="panel stack" data-panel-key="ai-stream" open>
<summary><h2>AI Stream</h2></summary>
<div id="ai" class="muted">Loading…</div>
</div>
</details>
</aside>
<section class="stack" id="work">
<div class="panel" id="issues">
<h2>Issues</h2>
<div class="muted">Loading…</div>
</div>
<div class="panel" id="prs">
<h2>Pull Requests</h2>
<div class="muted">Loading…</div>
</div>
<div class="panel">
<h2>Live Widgets</h2>
<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="widgets" open>
<summary><h2>Live Widgets</h2></summary>
<div id="widgets" class="stack"></div>
</div>
</details>
</section>
<aside class="sidebar">
<div class="panel stack">
<h2>Layout</h2>
<details class="panel stack" data-panel-key="layout" open>
<summary><h2>Layout</h2></summary>
<div class="muted" id="layout-hint"></div>
</div>
<div class="panel stack">
<h2>Markdown</h2>
</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>
</div>
</details>
</aside>
</main>
@ -143,6 +147,27 @@ textarea { resize: vertical; min-height: 120px; }
(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;
function setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
@ -162,14 +187,14 @@ textarea { resize: vertical; min-height: 120px; }
'<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');
const issuesBox = qs('#issues-content');
const openIssues = (data.issues || []).filter(i => i.state === 'open').slice(0, 12);
issuesBox.innerHTML = '<h2>Issues</h2>' + (openIssues.length ? openIssues.map(i => '<div style="margin:6px 0;"><a href="' + escAttr(i.url) + '" target="_blank">#' + i.number + ' ' + escapeHtml(i.title) + '</a>' +
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');
const prsBox = qs('#prs-content');
const openPrs = (data.pull_requests || []).slice(0, 12);
prsBox.innerHTML = '<h2>Pull Requests</h2>' + (openPrs.length ? openPrs.map(p => '<div style="margin:6px 0;"><a href="' + escAttr(p.url) + '" target="_blank">#' + p.number + ' ' + escapeHtml(p.title) + '</a>' +
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 || []);
@ -186,11 +211,11 @@ textarea { resize: vertical; min-height: 120px; }
{ number: 8, title: 'Live UI implementation', state: 'open', labels: ['frontend'], url: '#8' },
{ number: 37, title: 'Docker build and push', state: 'open', labels: ['devops'], url: '#37' },
];
qs('#issues').innerHTML = '<h2>Issues</h2>' + mockIssues.map(i => '<div style="margin:6px 0;"><a href="' + escAttr(i.url) + '">#' + i.number + ' ' + escapeHtml(i.title) + '</a><div class="muted">' + (i.labels||[]).map(l=>'<span class="pill">'+escapeHtml(String(l))+'</span>').join(' ') + '</div></div>').join('');
qs('#issues-content').innerHTML = mockIssues.map(i => '<div style="margin:6px 0;"><a href="' + escAttr(i.url) + '">#' + i.number + ' ' + escapeHtml(i.title) + '</a><div class="muted">' + (i.labels||[]).map(l=>'<span class="pill">'+escapeHtml(String(l))+'</span>').join(' ') + '</div></div>').join('');
const mockPrs = [
{ number: 4, title: 'Agent UI delta binding', state: 'open', user: 'turdbot', url: '#4' },
];
qs('#prs').innerHTML = '<h2>Pull Requests</h2>' + mockPrs.map(p => '<div style="margin:6px 0;"><a href="' + escAttr(p.url) + '">#' + p.number + ' ' + escapeHtml(p.title) + '</a><div class="muted">' + escapeHtml(p.state) + ' by ' + escapeHtml(String(p.user||'')) + '</div></div>').join('');
qs('#prs-content').innerHTML = mockPrs.map(p => '<div style="margin:6px 0;"><a href="' + escAttr(p.url) + '">#' + p.number + ' ' + escapeHtml(p.title) + '</a><div class="muted">' + escapeHtml(p.state) + ' by ' + escapeHtml(String(p.user||'')) + '</div></div>').join('');
paintDeltas([
{panel:'triage', action:'open', target:'#1', priority:'high'},
{panel:'review', action:'review', target:'#4', priority:'medium'},

View File

@ -0,0 +1,22 @@
from pathlib import Path
DASHBOARD = Path("frontend/index.html")
def test_dashboard_panels_are_independently_collapsible():
html = DASHBOARD.read_text()
for panel_key in ("context", "ai-stream", "issues", "pull-requests", "widgets", "layout", "markdown"):
assert f'<details class="panel' in html
assert f'data-panel-key="{panel_key}"' in html
def test_panel_open_state_is_restored_and_saved_in_local_storage():
html = DASHBOARD.read_text()
assert 'const PANEL_STATE_KEY = "stackchain.panel-state.v1"' in html
assert "localStorage.getItem(PANEL_STATE_KEY)" in html
assert "panel.open = savedState[panel.dataset.panelKey]" in html
assert "localStorage.setItem(PANEL_STATE_KEY" in html
assert "panel.addEventListener('toggle'" in html