diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 51bc6b1..99158d8 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -469,10 +469,12 @@ textarea { resize: vertical; min-height: 120px; }
.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; }
-.completed-filed-actions { position:fixed; right:0; bottom:0; z-index:57; box-sizing:border-box; width:min(560px,100%); display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; margin:0; padding:10px 12px calc(10px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:12px 0 0; background:rgba(11,21,38,.98); overflow-wrap:anywhere; }
+.completed-filed-actions { position:fixed; right:0; bottom:0; z-index:57; box-sizing:border-box; width:min(560px,100%); display:grid; grid-template-columns:minmax(0,1fr); align-items:center; gap:8px; margin:0; padding:10px 12px calc(10px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:12px 0 0; background:rgba(11,21,38,.98); overflow-wrap:anywhere; }
.completed-filed-actions[hidden] { display:none; }
.completed-filed-actions button { min-height:44px; min-width:0; }
-#issue-sheet:has(.completed-filed-actions:not([hidden])) .issue-sheet-panel { padding-bottom:calc(110px + env(safe-area-inset-bottom)); }
+.completed-filed-buttons { display:flex; flex-wrap:wrap; gap:8px; }
+.completed-filed-buttons button { flex:1 1 180px; }
+#issue-sheet:has(.completed-filed-actions:not([hidden])) .issue-sheet-panel { padding-bottom:calc(170px + env(safe-area-inset-bottom)); }
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
.checklist-step-editor { display:grid; gap:8px; margin:10px 0 16px; padding:12px; border:1px solid #31577f; border-radius:12px; background:#0b1526; }
.checklist-step-editor[hidden] { display:none; }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 9197b7a..d69d5a7 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -217,6 +217,7 @@
let selectedIssue = null;
let selectedIssueOffline = false;
let selectedIssueDetail = null;
+ let delegatedParentReview = null;
let dismissedChecklistBody = null;
let issueBlockerCandidates = [];
@@ -3502,6 +3503,7 @@
issueMentions.dismiss();
selectedIssueOffline = Boolean(offlineDetail);
selectedIssueDetail = null;
+ delegatedParentReview = null;
issueConversation = null;
issueTrigger = trigger;
qs('#issue-sheet').classList.add('open');
@@ -3513,6 +3515,8 @@
candidate.repository === item.repository && candidate.number === item.number
);
qs('#completed-filed-actions').hidden = !item.is_completed;
+ qs('#complete-parent-and-acknowledge').hidden = true;
+ qs('#complete-parent-and-acknowledge').disabled = true;
qs('#filed-claim-actions').hidden = true;
qs('#queue-filed-issue').disabled = true;
qs('#start-filed-issue').disabled = true;
@@ -3598,6 +3602,30 @@
qs('#issue-sheet-status').textContent = item.is_completed ?
'Completed Filed outcome ready · review the conversation' : readOnly ? 'Filed issue ready · follow-up enabled' :
'Issue ready · ' + (detail.state || 'open');
+ const parentReference = item.is_completed && !offlineDetail ?
+ issueController.delegatedParentReference(detail) : null;
+ if (parentReference) {
+ try {
+ const parentItem = { ...parentReference, key:parentReference.repository + '#' + parentReference.number };
+ const parentDetail = await issueController.load(parentItem);
+ if (selectedIssue !== item) return;
+ const relationship = issueController.resolveDelegatedParent(item, detail, parentDetail);
+ if (relationship && parentDetail.state === 'open' && parentDetail.updated_at) {
+ delegatedParentReview = { parentItem, parentDetail, relationship };
+ const button = qs('#complete-parent-and-acknowledge');
+ button.hidden = false;
+ button.disabled = false;
+ button.textContent = relationship.alreadyCompleted ?
+ 'Parent complete · acknowledge' : 'Complete parent step & acknowledge';
+ qs('#completed-filed-progress').textContent = relationship.alreadyCompleted ?
+ 'Parent checklist already reflects this outcome.' :
+ 'Delegated outcome ready · complete its parent checklist step.';
+ }
+ } catch (_error) {
+ if (selectedIssue !== item) return;
+ delegatedParentReview = null;
+ }
+ }
const revisableFiling = item.is_filed && !item.is_completed && detail.state === 'open';
qs('#edit-issue-content').textContent = revisableFiling ? 'Revise filing' : 'Edit issue';
qs('#edit-issue-content').disabled = readOnly && !revisableFiling;
@@ -3651,7 +3679,7 @@
}
window.addEventListener('popstate', closeIssueEditorFromHistory);
- qs('#acknowledge-completed-filed').addEventListener('click', () => {
+ function acknowledgeCompletedFiled() {
if (!selectedIssue?.is_completed) return;
if (!completedFiledReview.acknowledge(selectedIssue)) {
qs('#issue-sheet-status').textContent = 'Could not save this acknowledgement on this device. Retry.';
@@ -3674,6 +3702,30 @@
window.location.hash = '#/my-work/filed';
qs('#my-work').focus();
}
+ }
+
+ qs('#acknowledge-completed-filed').addEventListener('click', acknowledgeCompletedFiled);
+
+ qs('#complete-parent-and-acknowledge').addEventListener('click', async () => {
+ if (!selectedIssue?.is_completed || !delegatedParentReview) return;
+ const button = qs('#complete-parent-and-acknowledge');
+ button.disabled = true;
+ const { parentItem, parentDetail, relationship } = delegatedParentReview;
+ try {
+ if (!relationship.alreadyCompleted) {
+ const completion = issueController.completeDelegatedParentTask(parentDetail.body, selectedIssue.url);
+ await issueController.updateContent(parentItem, {
+ title:parentDetail.title, body:completion.body, expectedUpdatedAt:parentDetail.updated_at,
+ });
+ }
+ acknowledgeCompletedFiled();
+ } catch (error) {
+ if (!selectedIssue) return;
+ button.disabled = false;
+ qs('#issue-sheet-status').textContent =
+ 'Parent step was not changed. Reload the parent relationship and retry. ' + error.message;
+ button.focus();
+ }
});
function renderCheckSection(prefix, detail, offline = false) {
diff --git a/frontend/index.html b/frontend/index.html
index 839991d..53d7377 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -600,7 +600,10 @@
Choose an issue.
Completed Filed issue ready for review.
-
+
+
+
+
diff --git a/frontend/issue-sheet.js b/frontend/issue-sheet.js
index a352bb3..a74e86c 100644
--- a/frontend/issue-sheet.js
+++ b/frontend/issue-sheet.js
@@ -77,6 +77,47 @@ function manageChecklistTask(raw, targetIndex, operation = {}) {
return parts.join('');
}
+function completeDelegatedParentTask(raw, childUrl) {
+ const url = String(childUrl || '');
+ if (!/^https:\/\/[^\s)]+$/.test(url)) throw new Error('The delegated issue link is unavailable.');
+ const parts = String(raw || '').split(/(\r\n|\n|\r)/);
+ let fenced = false;
+ let taskIndex = 0;
+ for (let index = 0; index < parts.length; index += 2) {
+ const line = parts[index];
+ if (/^\s*```/.test(line)) { fenced = !fenced; continue; }
+ if (fenced) continue;
+ const task = line.match(/^(\s*[-*+]\s+\[)([ xX])(\]\s+.*)$/);
+ if (!task) continue;
+ const linkedTask = task[3].match(/^\]\s+\[[^\]]+\]\((https:\/\/[^\s)]+)\)\s*$/);
+ if (linkedTask?.[1] === url) {
+ const alreadyCompleted = task[2].toLocaleLowerCase() === 'x';
+ if (!alreadyCompleted) parts[index] = task[1] + 'x' + task[3];
+ return { body:parts.join(''), taskIndex, alreadyCompleted };
+ }
+ taskIndex += 1;
+ }
+ throw new Error('The parent checklist no longer links to this delegated issue.');
+}
+
+function delegatedParentReference(childDetail) {
+ const body = String(childDetail?.body || '');
+ const match = body.match(/(?:^|\n)Related to \[([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#([1-9]\d*)\]\((https:\/\/[^\s)]+)\)\.(?:\r?$|\s)/m);
+ if (!match) return null;
+ return { repository:match[1], number:Number(match[2]), url:match[3] };
+}
+
+function resolveDelegatedParent(child, childDetail, parentDetail) {
+ const parent = delegatedParentReference(childDetail);
+ if (!parent) return null;
+ try {
+ const completion = completeDelegatedParentTask(parentDetail?.body, child?.url);
+ return { parent, taskIndex:completion.taskIndex, alreadyCompleted:completion.alreadyCompleted };
+ } catch (_error) {
+ return null;
+ }
+}
+
function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.renderMarkdown, toggleTask = renderMarkdown?.toggleTask, manageTask: manageTaskTransform = manageChecklistTask, relatedTaskDraft: relatedTaskDraftTransform = (...args) => globalThis.createIssueCapture?.relatedChecklistDraft(...args), linkRelatedTask: linkRelatedTaskTransform = (...args) => globalThis.createIssueCapture?.linkChecklistTask(...args), enqueueDurably, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let closeRequest = null;
@@ -98,6 +139,9 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
const milestoneDraftKey = item => 'stackchain.issue-milestone.v1:' + item.repository + '#' + item.number;
return {
+ delegatedParentReference,
+ resolveDelegatedParent,
+ completeDelegatedParentTask,
readOnly(item) {
return Boolean(item?.is_completed || (item?.is_filed && !item?.is_assigned));
},
@@ -585,4 +629,7 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
createIssueSheet.createPlanningLoader = createPlanningLoader;
createIssueSheet.manageChecklistTask = manageChecklistTask;
+createIssueSheet.completeDelegatedParentTask = completeDelegatedParentTask;
+createIssueSheet.delegatedParentReference = delegatedParentReference;
+createIssueSheet.resolveDelegatedParent = resolveDelegatedParent;
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;
diff --git a/tests/test_my_work.py b/tests/test_my_work.py
index 48a2686..f92b7c2 100644
--- a/tests/test_my_work.py
+++ b/tests/test_my_work.py
@@ -100,6 +100,84 @@ process.stdout.write(JSON.stringify(buildMyWork({json.dumps(payload)})));
assert result[1]["reason"] == "Filed by you"
+def test_completed_child_resolves_only_a_canonical_reciprocal_parent_task():
+ script = f"""
+const sheet = require({json.dumps(str(ISSUE_SHEET))});
+const child = {{repository:'stackchain/dashboard', number:44,
+ url:'https://forge.example/git/stackchain/dashboard/issues/44'}};
+const childDetail = {{body:'Delivered.\\n\\nRelated to [stackchain/dashboard#12](https://forge.example/git/stackchain/dashboard/issues/12).'}};
+const parentDetail = {{title:'Launch', updated_at:'2026-08-16T10:00:00Z',
+ body:'Plan\\r\\n - [ ] [Ship mobile flow](https://forge.example/git/stackchain/dashboard/issues/44)\\r\\n- [ ] Other'}};
+const relationship = sheet.resolveDelegatedParent(child, childDetail, parentDetail);
+const unrelated = sheet.resolveDelegatedParent(child,
+ {{body:'Related to stackchain/dashboard#12 without a canonical link.'}}, parentDetail);
+const mismatch = sheet.resolveDelegatedParent(child, childDetail,
+ {{...parentDetail, body:'- [ ] [Different](https://forge.example/git/stackchain/dashboard/issues/45)'}});
+const incidental = sheet.resolveDelegatedParent(child, childDetail,
+ {{...parentDetail, body:'- [ ] Notes [child](https://forge.example/git/stackchain/dashboard/issues/44)'}});
+process.stdout.write(JSON.stringify({{relationship, unrelated, mismatch, incidental}}));
+"""
+ completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
+
+ assert completed.returncode == 0, completed.stderr
+ assert json.loads(completed.stdout) == {
+ "relationship": {
+ "parent": {
+ "repository": "stackchain/dashboard",
+ "number": 12,
+ "url": "https://forge.example/git/stackchain/dashboard/issues/12",
+ },
+ "taskIndex": 0,
+ "alreadyCompleted": False,
+ },
+ "unrelated": None,
+ "mismatch": None,
+ "incidental": None,
+ }
+
+
+def test_delegated_parent_completion_changes_only_the_matching_task_marker():
+ script = f"""
+const complete = require({json.dumps(str(ISSUE_SHEET))}).completeDelegatedParentTask;
+const childUrl = 'https://forge.example/git/stackchain/dashboard/issues/44';
+const body = 'Plan\\r\\n - [ ] [Ship mobile flow](' + childUrl + ')\\r\\n' +
+ '- [ ] Duplicate words\\r\\n- [x] [Already](https://forge.example/issues/9)';
+const changed = complete(body, childUrl);
+const repeated = complete(changed.body, childUrl);
+process.stdout.write(JSON.stringify({{changed, repeated}}));
+"""
+ completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
+
+ assert completed.returncode == 0, completed.stderr
+ assert json.loads(completed.stdout) == {
+ "changed": {
+ "body": "Plan\r\n - [x] [Ship mobile flow](https://forge.example/git/stackchain/dashboard/issues/44)\r\n- [ ] Duplicate words\r\n- [x] [Already](https://forge.example/issues/9)",
+ "taskIndex": 0,
+ "alreadyCompleted": False,
+ },
+ "repeated": {
+ "body": "Plan\r\n - [x] [Ship mobile flow](https://forge.example/git/stackchain/dashboard/issues/44)\r\n- [ ] Duplicate words\r\n- [x] [Already](https://forge.example/issues/9)",
+ "taskIndex": 0,
+ "alreadyCompleted": True,
+ },
+ }
+
+
+@pytest.mark.anyio
+async def test_completed_filed_review_offers_parent_completion_before_acknowledgement():
+ markup = (Path(__file__).parents[1] / "frontend" / "index.html").read_text()
+ source = await dashboard()
+
+ assert 'id="complete-parent-and-acknowledge"' in markup
+ assert 'Complete parent step & acknowledge' in markup
+ assert "resolveDelegatedParent(item, detail, parentDetail)" in source
+ handler = source.split("qs('#complete-parent-and-acknowledge').addEventListener('click'", 1)[1].split(
+ "function renderCheckSection", 1
+ )[0]
+ assert "await issueController.updateContent" in handler
+ assert handler.index("await issueController.updateContent") < handler.index("acknowledgeCompletedFiled")
+
+
def test_completed_filed_acknowledgement_survives_refresh_until_upstream_update():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
@@ -249,7 +327,9 @@ async def test_completed_filed_sheet_exposes_mobile_acknowledge_and_next_flow():
assert "createCompletedFiledReview({" in source
assert "lastMyWork = buildMyWork(data)" in source
assert "filed: 'filed issues'" in source
- handler = source.split("qs('#acknowledge-completed-filed').addEventListener('click'", 1)[1]
+ handler = source.split("function acknowledgeCompletedFiled()", 1)[1].split(
+ "qs('#acknowledge-completed-filed').addEventListener", 1
+ )[0]
assert "completedFiledReview.acknowledge(selectedIssue)" in handler
assert "syncCompletedFiledReviews()" in source
assert "api/v1/completed-filed-reviews" in source