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.
-
-
-
-
-
Full conversation
@@ -465,6 +431,47 @@ textarea { resize: vertical; min-height: 120px; }
+
+ Plan & edit
+
+
+
+
+
+
+
@@ -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},