diff --git a/README.md b/README.md
index b1069c7..db6b670 100644
--- a/README.md
+++ b/README.md
@@ -86,10 +86,13 @@ overwriting newer views. Rename and delete affect only the saved view, never Git
sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default
`.stackchain-state/saved-searches.sqlite3` path.
Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged.
-Acknowledgements hide the exact Gitea `updated_at` revision immediately on the current device, synchronize in
-bounded batches to the confirmed account, and suppress that outcome on other signed-in devices. A later Gitea
-update reopens review. Offline or failed synchronization keeps the local acknowledgement and retries on the next
-healthy dashboard refresh without blocking **Acknowledge & next**. Set
+Filed separates actionable **Needs review** from a browsable **Reviewed** history, so acknowledgement clears the
+queue without erasing the delegated-work record. Reviewed cards reopen the existing read-only issue detail and
+conversation, while the Filed badge continues to count actionable outcomes only. A later Gitea update moves that
+issue back to Needs review automatically. Acknowledgements synchronize the exact Gitea `updated_at` revision in
+bounded batches to the confirmed account and preserve the same Reviewed state on other signed-in devices. Offline
+or failed synchronization keeps the local acknowledgement and retries on the next healthy dashboard refresh
+without blocking **Acknowledge & next**. Set
`STACKCHAIN_COMPLETED_FILED_REVIEW_DB` to override the default
`.stackchain-state/completed-filed-reviews.sqlite3` path.
Search previews also
diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index e049427..d90998e 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -285,6 +285,10 @@ textarea { resize: vertical; min-height: 120px; }
.queue-finder button { padding-inline:14px; }
#search-older-work { width:100%; }
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
+.filed-history-tabs { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin:10px 0; }
+.filed-history-tabs[hidden] { display:none; }
+.filed-history-tabs button { min-height:44px; min-width:0; }
+.filed-history-tabs button[aria-pressed="true"] { border-color:#60a5fa; background:#17365d; }
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; scroll-margin-bottom:calc(76px + env(safe-area-inset-bottom)); }
.draft-card:focus-visible { outline:3px solid #60a5fa; outline-offset:3px; border-color:#93c5fd; }
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 8f37b97..2b91b08 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -61,7 +61,7 @@
let mobileQueueCounts = {};
function openFiledFollowUp() {
selectMobileQueue('filed');
- const target = filedFollowUpTarget(lastMyWork);
+ const target = filedFollowUpTarget(completedFiledReview.visible(lastMyWork));
if (!target) {
qs('#my-work-action-status').textContent = 'No filed issues are ready to open.';
return 'empty';
@@ -156,6 +156,7 @@
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1';
let selectedWorkFilter = 'all';
+ let selectedFiledView = 'needs-review';
let selectedWorkMilestone = 'all';
let queueFindQuery = '';
let savedWorkFilter = null;
@@ -220,6 +221,10 @@
storage: localStorage,
getLogin() { return planningOwnerLogin; },
});
+ const filedHistoryTabs = createFiledHistoryTabs({
+ root:qs('#filed-history-tabs'), review:completedFiledReview,
+ onSelect:view => { selectedFiledView = view; renderMyWork(); },
+ });
let completedFiledSyncFlight = null;
let activeMyWork = [];
let laterMyWork = [];
@@ -344,7 +349,7 @@
.then(snapshot => {
const changed = completedFiledReview.adopt(snapshot);
if (lastContextSnapshot && changed) {
- lastMyWork = completedFiledReview.visible(buildMyWork(lastContextSnapshot));
+ lastMyWork = buildMyWork(lastContextSnapshot);
refreshMyWorkView();
}
if (pending.length) qs('#my-work-action-status').textContent =
@@ -2545,7 +2550,7 @@
}
function paintMyWork(data) {
- lastMyWork = completedFiledReview.visible(buildMyWork(data));
+ lastMyWork = buildMyWork(data);
refreshMyWorkView();
void syncCompletedFiledReviews();
}
@@ -2566,7 +2571,8 @@
function refreshMyWorkView({ reconcileSession = true } = {}) {
lastDrafts = listDrafts();
- const partitioned = laterWork.partition(lastMyWork, {
+ const actionableMyWork = filedHistoryTabs.prepare(lastMyWork);
+ const partitioned = laterWork.partition(actionableMyWork, {
pruneMissing: !Object.values(workPagination).some(page => page?.has_more),
});
activeMyWork = partitioned.active;
@@ -2574,7 +2580,7 @@
const authoritativeTodayReconciliation = liveMode && hasContextSnapshot &&
!lastContextSnapshot?.error &&
!Object.values(workPagination).some(page => page?.has_more);
- todayMyWork = todayWork.reconcile(lastMyWork, {
+ todayMyWork = todayWork.reconcile(actionableMyWork, {
pruneMissing: authoritativeTodayReconciliation,
onPrune: retiredIds => {
const queued = retiredIds.map(id => todaySync.enqueue('remove', id)).every(Boolean);
@@ -2871,9 +2877,11 @@
renderDrafts();
qs('#bulk-mark-read-bar').hidden = true;
qs('#load-more-notifications').hidden = true;
+ qs('#filed-history-tabs').hidden = true;
return;
}
- const queueItems = selectedWorkFilter === 'today' ?
+ const filedItems = filedHistoryTabs.render(selectedFiledView, selectedWorkFilter === 'filed');
+ const queueItems = selectedWorkFilter === 'filed' ? filedItems : selectedWorkFilter === 'today' ?
filterMyWork(todayMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'later' ?
filterMyWork(laterMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'agenda' ?
agendaMyWork(activeMyWork) :
@@ -2954,8 +2962,9 @@
(selectedWorkFilter === 'agenda' && workPagination.issue?.has_more ?
(agendaChecking ? 'Checking all assigned deadlines…' :
'Older assigned deadlines remain unchecked. Retry the Agenda check.') : (incomplete ?
- 'More work is available. Load the next page.' :
- 'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.')) + '');
+ 'More work is available. Load the next page.' : (selectedWorkFilter === 'filed' && selectedFiledView === 'reviewed' ?
+ 'No reviewed outcomes yet' :
+ 'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.'))) + '');
cardPlanning.wire();
document.querySelectorAll('[data-select-notification-id]').forEach(input => {
input.addEventListener('change', () => {
@@ -3472,10 +3481,9 @@
return;
}
const acknowledged = selectedIssue;
- lastMyWork = completedFiledReview.visible(lastMyWork);
closeIssueSheet(false);
refreshMyWorkView();
- const target = filedFollowUpTarget(lastMyWork);
+ const target = filedFollowUpTarget(completedFiledReview.visible(lastMyWork));
qs('#my-work-action-status').textContent = 'Reviewed ' + acknowledged.key + '.' +
(target ? ' Opening the next Filed item. Acknowledgement sync pending.' :
' Filed review is complete. Acknowledgement sync pending.');
diff --git a/frontend/index.html b/frontend/index.html
index 828c867..7573e2c 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -247,6 +247,10 @@
+
+
+
+
diff --git a/frontend/my-work.js b/frontend/my-work.js
index 48fb565..bee668c 100644
--- a/frontend/my-work.js
+++ b/frontend/my-work.js
@@ -691,12 +691,22 @@ function createCompletedFiledReview({
repository:String(item?.repository || ''), number:item?.number, updated_at:stamp,
});
const save = value => storage?.setItem(ownerKey(), JSON.stringify({ version:1, ...value }));
+ const partition = items => {
+ const acknowledged = read().items;
+ const needsReview = [];
+ const reviewed = [];
+ (items || []).forEach(item => {
+ const isReviewed = item?.is_completed &&
+ acknowledged[identity(item)] === String(item.updated_at || '');
+ (isReviewed ? reviewed : needsReview).push(item);
+ });
+ reviewed.sort((left, right) => String(right.updated_at || '').localeCompare(String(left.updated_at || '')));
+ return { needsReview, reviewed };
+ };
return {
+ partition,
visible(items) {
- const acknowledged = read().items;
- return (items || []).filter(item =>
- !item?.is_completed || acknowledged[identity(item)] !== String(item.updated_at || '')
- );
+ return partition(items).needsReview;
},
pending() {
const state = read();
@@ -742,6 +752,28 @@ function createCompletedFiledReview({
};
}
+function createFiledHistoryTabs({ root, review, onSelect }) {
+ const buttons = Array.from(root.querySelectorAll('[data-filed-view]'));
+ let partition = { needsReview:[], reviewed:[] };
+ buttons.forEach(button => button.addEventListener('click', () => onSelect(button.dataset.filedView)));
+ return {
+ prepare(items) {
+ partition = review.partition(items);
+ root.querySelector('#filed-needs-review-count').textContent =
+ partition.needsReview.filter(item => item.is_filed).length;
+ root.querySelector('#filed-reviewed-count').textContent = partition.reviewed.length;
+ return partition.needsReview;
+ },
+ render(selected, visible) {
+ root.hidden = !visible;
+ buttons.forEach(button =>
+ button.setAttribute('aria-pressed', String(button.dataset.filedView === selected))
+ );
+ return selected === 'reviewed' ? partition.reviewed : partition.needsReview;
+ },
+ };
+}
+
function agendaMyWork(items, now = new Date()) {
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const today = localDay(start);
@@ -1058,6 +1090,7 @@ function countMyWork(items) {
if (typeof module !== 'undefined' && module.exports) {
buildMyWork.filterMyWork = filterMyWork;
buildMyWork.createCompletedFiledReview = createCompletedFiledReview;
+ buildMyWork.createFiledHistoryTabs = createFiledHistoryTabs;
buildMyWork.agendaMyWork = agendaMyWork;
buildMyWork.milestoneLanes = milestoneLanes;
buildMyWork.createWorkSession = createWorkSession;
diff --git a/tests/test_mobile_task_dock.py b/tests/test_mobile_task_dock.py
index 969557a..f96379a 100644
--- a/tests/test_mobile_task_dock.py
+++ b/tests/test_mobile_task_dock.py
@@ -582,7 +582,7 @@ async def test_dashboard_renders_and_wires_mobile_queue_switcher():
assert 'FiledIssues you delegated' in html
assert 'data-mobile-queue-count="filed"' in html
assert "openFiled: openFiledFollowUp" in html
- assert "filedFollowUpTarget(lastMyWork)" in html
+ assert "filedFollowUpTarget(completedFiledReview.visible(lastMyWork))" in html
assert 'data-mobile-queue="later"' in html
assert 'data-mobile-queue="draft"' in html
assert 'data-mobile-queue="recaps"' in html
diff --git a/tests/test_my_work.py b/tests/test_my_work.py
index 8732392..fc57a76 100644
--- a/tests/test_my_work.py
+++ b/tests/test_my_work.py
@@ -141,6 +141,40 @@ if (typeof create !== 'function') {{
}
+def test_completed_filed_review_partitions_acknowledged_history_until_a_new_revision():
+ script = f"""
+const buildMyWork = require({json.dumps(str(MY_WORK))});
+const values = new Map();
+const storage = {{
+ getItem:key => values.has(key) ? values.get(key) : null,
+ setItem:(key, value) => values.set(key, value),
+}};
+const create = buildMyWork.createCompletedFiledReview;
+const open = {{kind:'issue', key:'stackchain/api#10', repository:'stackchain/api', number:10,
+ is_filed:true, is_completed:false, updated_at:'2026-08-15T12:00:00Z'}};
+const completed = {{kind:'issue', key:'stackchain/api#9', repository:'stackchain/api', number:9,
+ is_filed:true, is_completed:true, updated_at:'2026-08-14T12:00:00Z'}};
+const review = create({{storage, getLogin:() => 'timmy'}});
+const before = review.partition([open, completed]);
+review.acknowledge(completed);
+const after = review.partition([open, completed]);
+const revised = review.partition([open, {{...completed, updated_at:'2026-08-16T12:00:00Z'}}]);
+const shape = value => ({{
+ needsReview:value.needsReview.map(item => item.number),
+ reviewed:value.reviewed.map(item => item.number),
+}});
+process.stdout.write(JSON.stringify({{before:shape(before), after:shape(after), revised:shape(revised)}}));
+"""
+ completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
+
+ assert completed.returncode == 0, completed.stderr
+ assert json.loads(completed.stdout) == {
+ "before": {"needsReview": [10, 9], "reviewed": []},
+ "after": {"needsReview": [10], "reviewed": [9]},
+ "revised": {"needsReview": [10, 9], "reviewed": []},
+ }
+
+
def test_completed_filed_review_merges_remote_receipts_and_keeps_local_work_pending():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
@@ -181,6 +215,27 @@ process.stdout.write(JSON.stringify({{pendingBefore, changed, visible, pendingAf
}
+@pytest.mark.anyio
+async def test_mobile_filed_exposes_actionable_and_reviewed_views_without_inflating_badge():
+ markup = (Path(__file__).parents[1] / "frontend" / "index.html").read_text()
+ css = (Path(__file__).parents[1] / "frontend" / "dashboard.css").read_text()
+ source = await dashboard()
+
+ assert 'id="filed-history-tabs"' in markup
+ assert 'data-filed-view="needs-review"' in markup
+ assert 'data-filed-view="reviewed"' in markup
+ assert 'No reviewed outcomes yet' in source
+ assert "const actionableMyWork = filedHistoryTabs.prepare(lastMyWork)" in source
+ assert "const counts = countMyWork(activeMyWork)" in source
+ assert "todayWork.reconcile(actionableMyWork" in source
+ assert "selectedFiledView === 'reviewed'" in source
+ assert "filedFollowUpTarget(completedFiledReview.visible(lastMyWork))" in source.split("function openFiledFollowUp()", 1)[1].split("}", 1)[0]
+ assert ".filed-history-tabs" in css
+ filed_tabs_rule = css.split(".filed-history-tabs button", 1)[1].split("}", 1)[0]
+ assert "min-height:44px" in filed_tabs_rule
+ assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
+
+
@pytest.mark.anyio
async def test_completed_filed_sheet_exposes_mobile_acknowledge_and_next_flow():
markup = (Path(__file__).parents[1] / "frontend" / "index.html").read_text()
@@ -191,14 +246,14 @@ async def test_completed_filed_sheet_exposes_mobile_acknowledge_and_next_flow():
assert 'id="acknowledge-completed-filed"' in markup
assert 'Acknowledge & next' in markup
assert "createCompletedFiledReview({" in source
- assert "completedFiledReview.visible(buildMyWork(data))" 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]
assert "completedFiledReview.acknowledge(selectedIssue)" in handler
assert "syncCompletedFiledReviews()" in source
assert "api/v1/completed-filed-reviews" in source
assert "sync pending" in source
- assert "filedFollowUpTarget(lastMyWork)" in handler
+ assert "filedFollowUpTarget(completedFiledReview.visible(lastMyWork))" in handler
assert "openRoutedWork" in handler
assert ".completed-filed-actions" in css
action_rule = css.split(".completed-filed-actions", 1)[1].split("}", 1)[0]
@@ -2928,7 +2983,7 @@ async def test_mobile_my_work_wires_touch_safe_non_mutating_later_actions():
assert 'data-work-count="later"' in html
assert 'const laterWork = createLaterWork({' in html
assert 'getLogin: () => planningOwnerLogin' in html
- assert 'laterWork.partition(lastMyWork,' in html
+ assert 'laterWork.partition(actionableMyWork,' in html
assert 'data-later-preset="today"' in html
assert 'data-later-preset="tomorrow"' in html
assert 'data-later-restore' in html