feat: surface Today issue blockers (#435)
All checks were successful
CI / lint (pull_request) Successful in 53s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-10 00:00:51 +00:00
parent e0de45ea90
commit 05f9e06a10
14 changed files with 270 additions and 23 deletions

View File

@ -37,7 +37,10 @@ the private content. Issue capture and authored mobile actions (issue
comments, pull-request comments, notification replies, and reviews) persist per-draft
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
another worker replays a confirmed result instead of posting duplicate content. The ordered,
five-item Today plan syncs across the operator's devices. Starting a Today work session also
five-item Today plan syncs across the operator's devices. Adding an issue through **Plan Today**
first previews its Gitea dependencies: unresolved blockers are listed with links and require the
explicit **Add blocked item anyway** override, while an unavailable dependency lookup is reported
as unknown rather than unblocked. Starting a Today work session also
stores an account-bound checkpoint on the current device. After a reload or installed-app
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed);
**Comment & next** on that current issue or pull request posts the handoff online or admits it

View File

@ -213,6 +213,11 @@ textarea { resize: vertical; min-height: 120px; }
.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-blockers { max-width:100%; overflow-x:hidden; margin:16px 0; padding:12px; border:1px solid #b45309; border-radius:12px; background:#291b0c; }
.issue-blockers h2 { margin-top:0; }
.issue-blocker-list { display:grid; gap:8px; }
.issue-blocker { min-width:0; overflow-wrap:anywhere; display:grid; gap:4px; padding:10px; border:1px solid #92400e; border-radius:10px; color:#fef3c7; text-decoration:none; }
.issue-blocker:hover, .issue-blocker:focus-visible { border-color:#f59e0b; }
.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%; }

View File

@ -872,7 +872,9 @@
const title = escapeHtml(item.title || 'Untitled work');
const controls = selected ?
'<div class="plan-today-item-actions"><button type="button" data-plan-move="up" data-plan-id="' + escAttr(id) + '"' + (index === 0 ? ' disabled' : '') + '>Up</button><button type="button" data-plan-move="down" data-plan-id="' + escAttr(id) + '"' + (index === planToday.snapshot().count - 1 ? ' disabled' : '') + '>Down</button><button type="button" data-plan-remove="' + escAttr(id) + '">Remove</button></div>' :
'<div class="plan-today-candidate-actions"><button type="button" data-plan-preview="' + escAttr(id) + '">Preview</button><button type="button" data-plan-add="' + escAttr(id) + '">Add</button></div>';
(item.kind === 'issue' ?
'<div class="plan-today-candidate-actions"><button type="button" data-plan-preview="' + escAttr(id) + '">Preview to add</button></div>' :
'<div class="plan-today-candidate-actions"><button type="button" data-plan-preview="' + escAttr(id) + '">Preview</button><button type="button" data-plan-add="' + escAttr(id) + '">Add</button></div>');
return '<article class="plan-today-item"><div class="plan-today-item-copy"><span class="small">' + key + '</span><strong class="my-work-card-title">' + title + '</strong></div>' + controls + '</article>';
}
@ -955,6 +957,7 @@
function openPlanPreviewDetail(item, trigger) {
qs('#plan-today-sheet').hidden = true;
qs('#plan-preview-actions').hidden = false;
if (item.kind === 'issue') renderPlanIssueDependencies(null, true);
if (offlineWorkMode) {
openRoutedWork(item, trigger);
} else if (item.kind === 'update' && item.has_update) {
@ -981,19 +984,68 @@
onClose: (_item, trigger) => {
closeOpenWorkSheets();
qs('#plan-preview-actions').hidden = true;
qs('#issue-blockers').hidden = true;
qs('#issue-blocker-list').textContent = '';
qs('#add-plan-preview').disabled = false;
qs('#add-plan-preview').textContent = 'Add to Today & back';
qs('#plan-today-sheet').hidden = false;
renderPlanToday();
requestAnimationFrame(() => trigger?.focus());
},
});
function renderPlanIssueDependencies(detail, loading = false) {
const preview = planTodayPreview.snapshot();
if (!preview.open || preview.item?.kind !== 'issue') return;
const panel = qs('#issue-blockers');
const list = qs('#issue-blocker-list');
const status = qs('#issue-blocker-status');
const addButton = qs('#add-plan-preview');
panel.hidden = false;
list.innerHTML = '';
addButton.disabled = loading;
addButton.dataset.planOverride = '';
if (loading) {
status.textContent = 'Checking unresolved blockers…';
addButton.textContent = 'Checking blockers…';
return;
}
const available = detail?.dependencies_available === true;
const dependencies = Array.isArray(detail?.dependencies) ? detail.dependencies : [];
planTodayPreview.setDependencies({ available, dependencies });
if (!available) {
status.textContent = 'Blocker status unavailable. Adding requires an explicit override.';
addButton.textContent = 'Add without blocker status anyway & back';
addButton.dataset.planOverride = 'true';
return;
}
if (dependencies.length) {
list.innerHTML = dependencies.map(blocker =>
'<a class="issue-blocker" href="' + escAttr(blocker.url || '#') + '" target="_blank" rel="noopener noreferrer"><strong>' +
escapeHtml(blocker.repository + '#' + blocker.number) + ' · ' + escapeHtml(blocker.title || 'Untitled blocker') +
'</strong><span class="small">State: ' + escapeHtml(blocker.state || 'open') + '</span></a>'
).join('');
status.textContent = dependencies.length + (dependencies.length === 1 ? ' unresolved blocker.' : ' unresolved blockers.');
addButton.textContent = 'Add blocked item anyway & back';
addButton.dataset.planOverride = 'true';
return;
}
panel.hidden = true;
status.textContent = '';
addButton.textContent = 'Add to Today & back';
}
let addPlanPreviewOnReturn = false;
let addPlanPreviewOverride = false;
qs('#back-to-plan').addEventListener('click', () => {
addPlanPreviewOnReturn = false;
addPlanPreviewOverride = false;
taskOverlayHistory.close();
});
qs('#add-plan-preview').addEventListener('click', () => {
addPlanPreviewOnReturn = true;
addPlanPreviewOverride = qs('#add-plan-preview').dataset.planOverride === 'true';
taskOverlayHistory.close();
});
@ -1783,6 +1835,7 @@
const detail = offlineDetail || await issueController.load(item);
if (selectedIssue !== item) return;
selectedIssueDetail = detail;
renderPlanIssueDependencies(detail);
issueConversation = issueController.conversation(item, detail.conversation);
qs('#issue-sheet-title').textContent = detail.title || 'Assigned issue';
qs('#issue-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
@ -2936,9 +2989,11 @@
qs('#open-palette').focus();
}
if (previous === 'plan-today-preview' && kind !== 'plan-today-preview') {
if (addPlanPreviewOnReturn) planTodayPreview.close({ add:true });
if (addPlanPreviewOnReturn && addPlanPreviewOverride) planTodayPreview.close({ add:true, override:true });
else if (addPlanPreviewOnReturn) planTodayPreview.close({ add:true });
else planTodayPreview.close();
addPlanPreviewOnReturn = false;
addPlanPreviewOverride = false;
}
if (previous === 'plan-today' && kind !== 'plan-today' && kind !== 'plan-today-preview') closePlanToday(false);
if (kind === 'new' && previous !== 'new') openCreateIssueSheet(false);

View File

@ -262,6 +262,11 @@
<button class="issue-retry" id="retry-issue-load" type="button" hidden>Reload latest issue</button>
<div class="row"><span id="issue-labels"></span><span class="small" id="issue-assignees"></span></div>
<div class="issue-sheet-content markdown-content" id="issue-sheet-body"></div>
<section class="issue-blockers" id="issue-blockers" aria-labelledby="issue-blockers-title" hidden>
<h2 id="issue-blockers-title">Blocked by</h2>
<div class="issue-blocker-list" id="issue-blocker-list"></div>
<div id="issue-blocker-status" class="small" aria-live="polite"></div>
</section>
<h2>Full conversation</h2>
<div id="issue-comments"></div>
<button class="conversation-more" id="load-older-issue-comments" type="button" hidden>Load older messages</button>

View File

@ -22,8 +22,19 @@
return true;
}
function close({ add = false } = {}) {
function setDependencies({ available, dependencies } = {}) {
if (!current) return false;
current.dependencies_available = available === true;
current.dependencies = Array.isArray(dependencies) ? dependencies.filter(item => item?.state === 'open') : [];
current.requires_override = !current.dependencies_available || current.dependencies.length > 0;
return true;
}
function close({ add = false, override = false } = {}) {
if (!current) return 'closed';
if (add && current.requires_override && !override) {
return current.dependencies_available ? 'blocked' : 'dependencies-unavailable';
}
const { item, trigger, scroll } = current;
let result = 'returned';
if (add) {
@ -37,9 +48,16 @@
}
function snapshot() {
return current ? { open:true, item:current.item, trigger:current.trigger, scroll:current.scroll } : { open:false };
if (!current) return { open:false };
const state = { open:true, item:current.item, trigger:current.trigger, scroll:current.scroll };
if (Object.prototype.hasOwnProperty.call(current, 'dependencies_available')) {
state.dependencies_available = current.dependencies_available;
state.dependencies = current.dependencies;
state.requires_override = current.requires_override;
}
return state;
}
return { open, close, snapshot };
return { open, close, setDependencies, snapshot };
};
});

View File

@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v72';
const CACHE = 'stackchain-dashboard-shell-v73';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;

View File

@ -1343,9 +1343,19 @@ async def issue_conversation_page(
async def issue_detail(repository: str, number: int) -> dict:
base = f"repos/{repository}/issues/{number}"
issue, conversation = await asyncio.gather(
fetch(base), issue_conversation_page(repository, number)
async def load_dependencies() -> tuple[bool, list[dict]]:
try:
return True, await issue_dependencies(repository, number)
except Exception:
return False, []
issue, conversation, dependency_result = await asyncio.gather(
fetch(base),
issue_conversation_page(repository, number),
load_dependencies(),
)
dependencies_available, dependencies = dependency_result
if not isinstance(issue, dict):
raise ValueError("Gitea issue response was not an object")
labels_value = issue.get("labels")
@ -1377,11 +1387,43 @@ async def issue_detail(repository: str, number: int) -> dict:
assignee["login"] for assignee in assignees
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
],
"dependencies_available": dependencies_available,
"dependencies": dependencies,
"comments": normalized_comments,
"conversation": conversation,
}
async def issue_dependencies(repository: str, number: int, limit: int = 20) -> list[dict]:
"""Return bounded open prerequisites for an issue."""
response = await _get_client().get(
f"/api/v1/repos/{repository}/issues/{number}/dependencies",
headers=_auth(),
params={"limit": limit},
)
response.raise_for_status()
value = response.json()
items = value if isinstance(value, list) else []
dependencies = []
for item in items[:limit]:
if not isinstance(item, dict) or item.get("state") != "open":
continue
repo_value = item.get("repository")
repo = repo_value if isinstance(repo_value, dict) else {}
dependency_repository = repo.get("full_name")
dependency_number = item.get("number")
if not isinstance(dependency_repository, str) or not isinstance(dependency_number, int):
continue
dependencies.append({
"repository": dependency_repository,
"number": dependency_number,
"title": item.get("title", "") if isinstance(item.get("title"), str) else "",
"state": "open",
"url": _safe_web_url(item.get("html_url")),
})
return dependencies
async def update_assigned_issue(
repository: str,
number: int,

View File

@ -1543,6 +1543,25 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
async def handler(request):
requests.append((request.method, request.url.path, request.url.query.decode()))
if request.url.path.endswith("/dependencies"):
return httpx.Response(
200,
json=[
{
"number": 3,
"title": "Restore signing service",
"state": "open",
"html_url": "https://forge.example/stackchain/platform/issues/3",
"repository": {"full_name": "stackchain/platform"},
},
{
"number": 2,
"title": "Completed prerequisite",
"state": "closed",
"repository": {"full_name": "stackchain/platform"},
},
],
)
if request.url.path.endswith("/comments"):
return httpx.Response(
200,
@ -1580,6 +1599,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
assert requests == [
("GET", "/api/v1/repos/stackchain/api/issues/7", ""),
("GET", "/api/v1/repos/stackchain/api/issues/7/comments", "limit=20&page=1"),
("GET", "/api/v1/repos/stackchain/api/issues/7/dependencies", "limit=20"),
]
assert result == {
"repository": "stackchain/api",
@ -1593,6 +1613,16 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
"url": "https://forge.example/stackchain/api/issues/7",
"labels": ["P1"],
"assignees": ["timmy"],
"dependencies_available": True,
"dependencies": [
{
"repository": "stackchain/platform",
"number": 3,
"title": "Restore signing service",
"state": "open",
"url": "https://forge.example/stackchain/platform/issues/3",
}
],
"comments": [
{
"id": 81,
@ -1619,6 +1649,31 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
}
@pytest.mark.anyio
async def test_gitea_issue_detail_keeps_planning_usable_when_dependencies_are_unavailable():
async def handler(request):
if request.url.path.endswith("/dependencies"):
return httpx.Response(503, json={"message": "upstream timeout"})
if request.url.path.endswith("/comments"):
return httpx.Response(200, json=[])
return httpx.Response(200, json={
"number": 7,
"title": "Fix mobile flow",
"state": "open",
"assignees": [{"login": "timmy"}],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.issue_detail("stackchain/api", 7)
finally:
await gitea_proxy.stop_client()
assert result["title"] == "Fix mobile flow"
assert result["dependencies_available"] is False
assert result["dependencies"] == []
@pytest.mark.anyio
async def test_gitea_conversation_page_opens_newest_page_and_reports_older_history():
requests = []

View File

@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v72" in source
assert "stackchain-dashboard-shell-v73" in source
assert "BASE + 'static/later-sync.js'" in source

View File

@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v72" in worker
assert "stackchain-dashboard-shell-v73" in worker

View File

@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v72" in worker
assert "stackchain-dashboard-shell-v73" in worker

View File

@ -126,6 +126,64 @@ process.stdout.write(JSON.stringify({{first, second, scroll, events, planner:pla
}
def test_plan_today_preview_requires_explicit_override_for_blocked_or_unknown_issue():
script = f"""
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
const createPlanTodayPreview = require({json.dumps(str(PLAN_TODAY_PREVIEW))});
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
const planner = createPlanToday({{
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
save: () => true,
}});
planner.open([], [item(2), item(3)]);
const preview = createPlanTodayPreview({{
planner,
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
}});
preview.open(item(2));
preview.setDependencies({{available:true, dependencies:[{{repository:'stackchain/api', number:9, title:'Restore API', state:'open'}}]}});
const blockedState = preview.snapshot();
const blocked = preview.close({{add:true}});
const overridden = preview.close({{add:true, override:true}});
preview.open(item(3));
preview.setDependencies({{available:false, dependencies:[]}});
const unavailableState = preview.snapshot();
const unavailable = preview.close({{add:true}});
const unavailableOverride = preview.close({{add:true, override:true}});
process.stdout.write(JSON.stringify({{blockedState, blocked, overridden, unavailableState, unavailable, unavailableOverride, planner:planner.snapshot()}}));
"""
assert run_node(script) == {
"blockedState": {
"open": True,
"item": {"kind": "issue", "repository": "stackchain/dashboard", "number": 2, "title": "Issue 2"},
"trigger": None,
"scroll": 0,
"dependencies_available": True,
"dependencies": [{"repository": "stackchain/api", "number": 9, "title": "Restore API", "state": "open"}],
"requires_override": True,
},
"blocked": "blocked",
"overridden": "added",
"unavailableState": {
"open": True,
"item": {"kind": "issue", "repository": "stackchain/dashboard", "number": 3, "title": "Issue 3"},
"trigger": None,
"scroll": 0,
"dependencies_available": False,
"dependencies": [],
"requires_override": True,
},
"unavailable": "dependencies-unavailable",
"unavailableOverride": "added",
"planner": {
"open": True,
"ids": ["issue:stackchain/dashboard:2:", "issue:stackchain/dashboard:3:"],
"count": 2,
"limit": 5,
},
}
@pytest.mark.anyio
async def test_mobile_dashboard_wires_focused_plan_today_sheet():
html = await dashboard()
@ -150,6 +208,12 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet():
assert 'id="add-plan-preview"' in html
assert "taskOverlayHistory.open('plan-today-preview')" in html
assert "planTodayPreview.close({ add:true })" in html
assert 'id="issue-blockers"' in html
assert "planTodayPreview.setDependencies({" in html
assert "Add blocked item anyway & back" in html
assert "Blocker status unavailable" in html
assert ".issue-blockers { max-width:100%; overflow-x:hidden;" in html
assert ".issue-blocker { min-width:0; overflow-wrap:anywhere;" in html
assert "Preview unavailable offline" in html
@ -168,6 +232,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v72" in source
assert "stackchain-dashboard-shell-v73" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source

View File

@ -122,7 +122,7 @@ async function dispatchNotificationClick(route) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v72" in source
assert "stackchain-dashboard-shell-v73" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@ -131,7 +131,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v72" in source
assert "stackchain-dashboard-shell-v73" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -139,7 +139,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v72" in source
assert "stackchain-dashboard-shell-v73" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -147,14 +147,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v72" in source
assert "stackchain-dashboard-shell-v73" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v72" in source
assert "stackchain-dashboard-shell-v73" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@ -163,21 +163,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v72" in source
assert "stackchain-dashboard-shell-v73" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v72" in source
assert "stackchain-dashboard-shell-v73" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v72" in source
assert "stackchain-dashboard-shell-v73" in source
assert "BASE + 'static/update-ownership.js'" in source
@ -358,7 +358,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v72" in source
assert "stackchain-dashboard-shell-v73" in source
assert "BASE + 'static/queue-today.js'" in source

View File

@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v72" in source
assert "stackchain-dashboard-shell-v73" in source
assert "BASE + 'static/today-sync.js'" in source