From d09ceb86502e632f66e8214c2e198a6947e8acbe Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 7 Aug 2026 17:38:01 +0000 Subject: [PATCH] feat: make mobile issue sheets read-first (#215) --- frontend/index.html | 171 ++++++++++++++++++++++------------------ frontend/issue-sheet.js | 22 ++++++ tests/test_my_work.py | 65 +++++++++++++++ 3 files changed, 182 insertions(+), 76 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index c7c2683..3c156e6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -142,6 +142,9 @@ 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-planning { max-width:100%; margin-top:16px; border:1px solid #2a496e; border-radius:12px; padding:0 12px 12px; overflow-x:hidden; } +.issue-planning > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; } +.issue-planning-retry { min-height:44px; width:100%; } #edit-issue-content { min-height:44px; width:100%; } .issue-edit-form { display:grid; gap:8px; max-width:100%; margin:12px 0; } .issue-edit-form label { display:grid; gap:6px; min-width:0; } @@ -417,44 +420,7 @@ textarea { resize: vertical; min-height: 120px; }
Choose an issue.
-
- Labels -
-
Load an issue to edit labels.
- -
-
- Due date - - -
- - -
-
Load an issue to schedule it.
-
-
- Milestone - - - -
Load an issue to plan it.
-

- -

Full conversation

@@ -465,6 +431,47 @@ textarea { resize: vertical; min-height: 120px; }
+
+ Plan & edit + + + +
+ Labels +
+
Expand planning controls to load labels.
+ +
+
+ Due date + + +
+ + +
+
Load an issue to schedule it.
+
+
+ Milestone + + + +
Expand planning controls to load milestones.
+
+
@@ -764,6 +771,10 @@ textarea { resize: vertical; min-height: 120px; } } const reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage }); const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage }); + const planningLoader = createIssueSheet.createPlanningLoader({ + loadLabels: item => issueController.loadLabels(item), + loadMilestones: item => issueController.loadMilestones(item), + }); const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage }); const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage }); const draftInbox = createDraftInbox({ storage: localStorage }); @@ -1356,29 +1367,21 @@ textarea { resize: vertical; min-height: 120px; } comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.'; } - async function loadIssueLabelEditor(item, confirmedNames) { + function renderIssueLabelEditor(item, confirmedNames, labels) { const list = qs('#issue-label-list'); const status = qs('#issue-label-status'); list.textContent = ''; - qs('#save-issue-labels').disabled = true; - status.textContent = 'Loading labels…'; - try { - const labels = await issueController.loadLabels(item); - if (selectedIssue !== item) return; - const draftIds = issueController.loadLabelDraft(item); - const selectedIds = draftIds.length ? new Set(draftIds) : new Set( - labels.filter(label => confirmedNames.includes(label.name)).map(label => Number(label.id)) - ); - list.innerHTML = labels.map(label => - '' - ).join(''); - status.textContent = labels.length ? 'Choose labels, then save.' : 'This repository has no labels.'; - qs('#save-issue-labels').disabled = false; - } catch (_error) { - status.textContent = 'Labels could not be loaded. Retry by reopening this issue.'; - } + const draftIds = issueController.loadLabelDraft(item); + const selectedIds = draftIds.length ? new Set(draftIds) : new Set( + labels.filter(label => confirmedNames.includes(label.name)).map(label => Number(label.id)) + ); + list.innerHTML = labels.map(label => + '' + ).join(''); + status.textContent = labels.length ? 'Choose labels, then save.' : 'This repository has no labels.'; + qs('#save-issue-labels').disabled = false; } function selectedEditIssueLabelIds() { @@ -1386,27 +1389,38 @@ textarea { resize: vertical; min-height: 120px; } .map(input => Number(input.value)).filter(Number.isInteger); } - async function loadIssueMilestoneEditor(item, confirmedMilestone) { + function renderIssueMilestoneEditor(item, confirmedMilestone, milestones) { const select = qs('#issue-milestone'); const status = qs('#issue-milestone-status'); select.innerHTML = ''; - select.disabled = true; - qs('#save-issue-milestone').disabled = true; - status.textContent = 'Loading milestones…'; + select.innerHTML += milestones.map(milestone => + '' + ).join(''); + const draft = issueController.loadMilestoneDraft(item); + select.value = String(draft ?? confirmedMilestone?.id ?? ''); + select.disabled = false; + qs('#save-issue-milestone').disabled = false; + status.textContent = confirmedMilestone ? + 'Planned for ' + confirmedMilestone.title + '.' : 'No milestone set.'; + } + + async function loadIssuePlanning() { + const item = selectedIssue; + const detail = selectedIssueDetail; + if (!item || !detail) return; + qs('#retry-issue-planning').hidden = true; + qs('#issue-label-status').textContent = 'Loading labels…'; + qs('#issue-milestone-status').textContent = 'Loading milestones…'; try { - const milestones = await issueController.loadMilestones(item); + const planning = await planningLoader.open(selectedIssue); if (selectedIssue !== item) return; - select.innerHTML += milestones.map(milestone => - '' - ).join(''); - const draft = issueController.loadMilestoneDraft(item); - select.value = String(draft ?? confirmedMilestone?.id ?? ''); - select.disabled = false; - qs('#save-issue-milestone').disabled = false; - status.textContent = confirmedMilestone ? - 'Planned for ' + confirmedMilestone.title + '.' : 'No milestone set.'; + renderIssueLabelEditor(item, detail.labels || [], planning.labels); + renderIssueMilestoneEditor(item, detail.milestone, planning.milestones); } catch (_error) { - status.textContent = 'Milestones could not be loaded. Retry by reopening this issue.'; + if (selectedIssue !== item) return; + qs('#issue-label-status').textContent = 'Labels could not be loaded.'; + qs('#issue-milestone-status').textContent = 'Milestones could not be loaded.'; + qs('#retry-issue-planning').hidden = false; } } @@ -1428,8 +1442,10 @@ textarea { resize: vertical; min-height: 120px; } qs('#issue-conversation-status').textContent = 'Loading newest messages…'; qs('#issue-comment').value = issueController.loadDraft(item); qs('#issue-comment-status').textContent = ''; + qs('#issue-planning').open = false; + qs('#retry-issue-planning').hidden = true; qs('#issue-label-list').textContent = ''; - qs('#issue-label-status').textContent = 'Loading labels…'; + qs('#issue-label-status').textContent = 'Expand planning controls to load labels.'; qs('#save-issue-labels').disabled = true; qs('#issue-due-date').value = ''; qs('#issue-due-date').disabled = true; @@ -1439,7 +1455,7 @@ textarea { resize: vertical; min-height: 120px; } qs('#issue-milestone').innerHTML = ''; qs('#issue-milestone').disabled = true; qs('#save-issue-milestone').disabled = true; - qs('#issue-milestone-status').textContent = 'Loading milestones…'; + qs('#issue-milestone-status').textContent = 'Expand planning controls to load milestones.'; qs('#retry-issue-load').hidden = true; qs('#open-issue-gitea').href = item.url || '#'; qs('#send-issue-comment').disabled = false; @@ -1458,8 +1474,6 @@ textarea { resize: vertical; min-height: 120px; } qs('#issue-labels').innerHTML = (detail.labels || []).map(label => '' + escapeHtml(label) + '' ).join(' '); - loadIssueLabelEditor(item, detail.labels || []); - loadIssueMilestoneEditor(item, detail.milestone); qs('#issue-assignees').textContent = (detail.assignees || []).length ? 'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported'; renderIssueConversation(issueConversation.snapshot()); @@ -1473,6 +1487,7 @@ textarea { resize: vertical; min-height: 120px; } qs('#clear-issue-due-date').disabled = !detail.due_date; qs('#issue-due-status').textContent = detail.due_date ? 'Due ' + new Date(detail.due_date).toLocaleDateString() : 'No due date set.'; + if (qs('#issue-planning').open) loadIssuePlanning(); } catch (error) { if (selectedIssue !== item) return; qs('#issue-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.'; @@ -2346,6 +2361,10 @@ textarea { resize: vertical; min-height: 120px; } qs('#retry-issue-load').addEventListener('click', () => { if (selectedIssue) openIssueSheet(selectedIssue, issueTrigger); }); + qs('#issue-planning').addEventListener('toggle', event => { + if (event.currentTarget.open) loadIssuePlanning(); + }); + qs('#retry-issue-planning').addEventListener('click', loadIssuePlanning); qs('#edit-issue-content').addEventListener('click', () => { if (!selectedIssue || !selectedIssueDetail?.updated_at) return; const draft = issueController.loadEditDraft(selectedIssue) || { diff --git a/frontend/issue-sheet.js b/frontend/issue-sheet.js index d17069d..cf1285c 100644 --- a/frontend/issue-sheet.js +++ b/frontend/issue-sheet.js @@ -1,3 +1,23 @@ +function createPlanningLoader({ loadLabels, loadMilestones }) { + const requests = new Map(); + const keyFor = item => item.repository + '#' + item.number; + + return { + open(item) { + const key = keyFor(item); + if (requests.has(key)) return requests.get(key); + const request = Promise.all([loadLabels(item), loadMilestones(item)]) + .then(([labels, milestones]) => ({ labels, milestones })) + .catch(error => { + if (requests.get(key) === request) requests.delete(key); + throw error; + }); + requests.set(key, request); + return request; + }, + }; +} + function createIssueSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) { let commentRequest = null; let closeRequest = null; @@ -213,4 +233,6 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global }; } +createIssueSheet.createPlanningLoader = createPlanningLoader; + if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet; diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 7168cb1..3239b7b 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -535,6 +535,71 @@ async def test_mobile_my_work_exposes_touch_safe_milestone_lane_and_issue_editor assert 'buildMyWork.replaceIssueMilestone(' in html +@pytest.mark.anyio +async def test_mobile_issue_sheet_puts_reading_before_collapsed_planning_controls(): + html = await dashboard() + + body = html.index('id="issue-sheet-body"') + conversation = html.index('

Full conversation

', body) + planning = html.index('id="issue-planning"', conversation) + labels = html.index('id="issue-label-editor"', planning) + milestone = html.index('id="issue-milestone"', planning) + + assert 'Plan & edit' in html[planning:labels] + assert '
' in html + assert body < conversation < planning < labels < milestone + + +def test_issue_planning_metadata_is_lazy_cached_and_retryable_after_failure(): + script = f""" +const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); +const calls = []; +let fail = true; +const planning = createIssueSheet.createPlanningLoader({{ + loadLabels: async item => {{ calls.push('labels:' + item.number); return [{{id:1,name:'P0'}}]; }}, + loadMilestones: async item => {{ + calls.push('milestones:' + item.number); + if (fail) throw new Error('offline'); + return [{{id:9,title:'RC'}}]; + }}, +}}); +const item = {{repository:'stackchain/api', number:17}}; +(async () => {{ + const before = calls.slice(); + let failed = false; + try {{ await planning.open(item); }} catch (_error) {{ failed = true; }} + fail = false; + const first = await planning.open(item); + const second = await planning.open(item); + process.stdout.write(JSON.stringify({{before, failed, calls, same:first === second, first}})); +}})(); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + + assert json.loads(result.stdout) == { + "before": [], + "failed": True, + "calls": ["labels:17", "milestones:17", "labels:17", "milestones:17"], + "same": True, + "first": {"labels": [{"id": 1, "name": "P0"}], "milestones": [{"id": 9, "title": "RC"}]}, + } + + +@pytest.mark.anyio +async def test_opening_issue_defers_planning_requests_until_disclosure_expands(): + html = await dashboard() + open_handler = html[html.index('async function openIssueSheet'):html.index('function closeIssueSheet')] + + assert 'loadIssueLabelEditor(' not in open_handler + assert 'loadIssueMilestoneEditor(' not in open_handler + assert "if (qs('#issue-planning').open) loadIssuePlanning();" in open_handler + assert "qs('#issue-planning').addEventListener('toggle'" in html + assert 'planningLoader.open(selectedIssue)' in html + assert 'id="retry-issue-planning"' in html + + def test_my_work_reviews_filter_and_summary_are_actionable(): items = [ {"title": "Issue", "kind": "issue", "is_review": False, "is_assigned": True}, -- 2.43.0