diff --git a/frontend/index.html b/frontend/index.html
index 8375937..71da5d5 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -157,6 +157,11 @@ textarea { resize: vertical; min-height: 120px; }
.new-issue { min-height:44px; }
.find-work-action { min-height:44px; }
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
+.start-work-session { min-height:44px; }
+.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
+.work-session-nav[hidden] { display:none; }
+.work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; }
+.work-session-nav button { min-height:44px; width:100%; }
.find-work-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.find-work-sheet.open { display:flex; }
.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
@@ -241,6 +246,7 @@ textarea { resize: vertical; min-height: 120px; }
Loading assigned work…
+
@@ -403,6 +409,11 @@ textarea { resize: vertical; min-height: 120px; }
Open in Gitea
+
@@ -482,6 +493,11 @@ textarea { resize: vertical; min-height: 120px; }
Open in Gitea
+
@@ -509,6 +525,11 @@ textarea { resize: vertical; min-height: 120px; }
Open in Gitea
+
@@ -564,6 +585,11 @@ textarea { resize: vertical; min-height: 120px; }
Review history
+
@@ -837,6 +863,59 @@ textarea { resize: vertical; min-height: 120px; }
onClose: () => closeUpdateSheet(false),
});
+ function closeOpenWorkSheets() {
+ ['#issue-sheet', '#pull-sheet', '#review-sheet', '#update-sheet'].forEach(selector =>
+ qs(selector).classList.remove('open')
+ );
+ selectedIssue = null;
+ selectedIssueDetail = null;
+ selectedPull = null;
+ selectedPullDetail = null;
+ selectedReview = null;
+ selectedUpdate = null;
+ }
+
+ function openWorkSessionItem(item) {
+ closeOpenWorkSheets();
+ if (selectedWorkFilter === 'update' && item.has_update && Number.isInteger(item.notification_id)) {
+ updateTrigger = null;
+ notificationReader.open(item, lastMyWork);
+ } else if (item.is_review) {
+ openReviewSheet(item, null);
+ } else if (item.kind === 'issue') {
+ openIssueSheet(item, null);
+ } else if (item.kind === 'pull') {
+ openPullSheet(item, null);
+ } else if (item.has_update && Number.isInteger(item.notification_id)) {
+ updateTrigger = null;
+ notificationReader.open(item, lastMyWork);
+ }
+ }
+
+ const workSession = createWorkSession({
+ getItems: () => lastMyWork,
+ getFilter: () => selectedWorkFilter,
+ onOpen: openWorkSessionItem,
+ onProgress: state => {
+ document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = false; });
+ document.querySelectorAll('[data-work-session-progress]').forEach(element => {
+ element.textContent = 'Item ' + state.index + ' of ' + state.total;
+ });
+ document.querySelectorAll('[data-work-session-previous]').forEach(button => {
+ button.disabled = !state.can_previous;
+ });
+ document.querySelectorAll('[data-work-session-next]').forEach(button => {
+ button.textContent = state.can_next ? 'Next work item' : 'Finish session';
+ });
+ },
+ onFinish: () => {
+ closeOpenWorkSheets();
+ document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; });
+ qs('#my-work-action-status').textContent = 'Work session complete.';
+ qs('#start-work-session').focus();
+ },
+ });
+
function renderContextSnapshot(data) {
liveMode = true;
hasContextSnapshot = true;
@@ -914,6 +993,7 @@ textarea { resize: vertical; min-height: 120px; }
summarizeMyWork(lastMyWork) : 'No assigned work, review requests, or unread updates.';
updateWorkPaginationControls();
renderMyWork();
+ if (workSession.active()) workSession.reconcile();
}
function activeWorkStreams() {
@@ -1955,8 +2035,8 @@ textarea { resize: vertical; min-height: 120px; }
lastContextSnapshot = buildMyWork.removeIssue(
lastContextSnapshot, releasing.repository, releasing.number
);
- paintMyWork(lastContextSnapshot);
closeIssueSheet();
+ paintMyWork(lastContextSnapshot);
qs('#my-work-action-status').textContent = releasing.key + ' released.' +
(confirmed.available ? ' It is available in Find Work.' : ' Other assignees remain.');
} catch (error) {
@@ -1973,10 +2053,10 @@ textarea { resize: vertical; min-height: 120px; }
qs('#issue-sheet-status').textContent = 'Closing issue…';
try {
await issueController.close(selectedIssue);
+ closeIssueSheet();
lastMyWork = lastMyWork.filter(item =>
!(item.kind === 'issue' && item.repository === closing.repository && item.number === closing.number)
);
- closeIssueSheet();
refreshMyWorkView();
qs('#my-work-action-status').textContent = closing.key + ' closed.';
} catch (error) {
@@ -2025,10 +2105,10 @@ textarea { resize: vertical; min-height: 120px; }
qs('#pull-sheet-status').textContent = 'Merging pull request…';
try {
await pullController.merge(selectedPull, selectedPullDetail.head_sha);
+ closePullSheet();
lastMyWork = lastMyWork.filter(item =>
!(item.kind === 'pull' && item.repository === merging.repository && item.number === merging.number)
);
- closePullSheet();
refreshMyWorkView();
qs('#my-work-action-status').textContent = merging.key + ' merged.';
} catch (error) {
@@ -2130,7 +2210,8 @@ textarea { resize: vertical; min-height: 120px; }
if (progress) showReviewProgress(progress.snapshot());
qs('#review-submit-status').textContent = 'Review submitted · ' + (result.state || 'complete') + '.';
await load();
- button.focus();
+ if (workSession.active()) workSession.complete();
+ else button.focus();
} catch (error) {
qs('#review-submit-status').textContent = error.message + ' Your draft is safe; retry or open in Gitea.';
button.disabled = false;
@@ -2190,6 +2271,19 @@ textarea { resize: vertical; min-height: 120px; }
function load() { return contextPoller.refresh(); }
qs('#refresh').addEventListener('click', load);
+ qs('#start-work-session').addEventListener('click', () => {
+ if (!filterMyWork(lastMyWork, selectedWorkFilter).length) {
+ qs('#my-work-action-status').textContent = 'No visible work to start.';
+ return;
+ }
+ workSession.start();
+ });
+ document.querySelectorAll('[data-work-session-previous]').forEach(button =>
+ button.addEventListener('click', () => workSession.previous())
+ );
+ document.querySelectorAll('[data-work-session-next]').forEach(button =>
+ button.addEventListener('click', () => workSession.next())
+ );
qs('#load-more-notifications').addEventListener('click', () =>
notificationPager.loadMore(lastNotifications)
);
diff --git a/frontend/my-work.js b/frontend/my-work.js
index 92c7342..4a55366 100644
--- a/frontend/my-work.js
+++ b/frontend/my-work.js
@@ -360,6 +360,84 @@ function filterMyWork(items, selectedFilter) {
return items.filter((item) => item.kind === selectedFilter);
}
+function workIdentity(item) {
+ if (!item) return '';
+ const kind = item.is_review ? 'review' : (item.kind || 'work');
+ const repository = item.repository || '';
+ const number = Number.isInteger(item.number) ? item.number : '';
+ const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
+ return [kind, repository, number, notification].join(':');
+}
+
+function createWorkSession({ getItems, getFilter, onOpen, onProgress, onFinish }) {
+ let currentIdentity = '';
+ let currentIndex = -1;
+ let running = false;
+
+ const queue = () => filterMyWork(getItems() || [], getFilter());
+ const report = (items, index) => onProgress({
+ index: index + 1,
+ total: items.length,
+ can_previous: index > 0,
+ can_next: index < items.length - 1,
+ });
+ const finish = () => {
+ running = false;
+ currentIdentity = '';
+ currentIndex = -1;
+ onFinish();
+ return false;
+ };
+ const openAt = (items, index) => {
+ if (!items.length || index < 0 || index >= items.length) return finish();
+ currentIndex = index;
+ currentIdentity = workIdentity(items[index]);
+ report(items, index);
+ onOpen(items[index]);
+ return true;
+ };
+
+ return {
+ active: () => running,
+ start() {
+ const items = queue();
+ if (!items.length) return finish();
+ running = true;
+ return openAt(items, 0);
+ },
+ reconcile() {
+ if (!running) return false;
+ const items = queue();
+ const index = items.findIndex(item => workIdentity(item) === currentIdentity);
+ if (index < 0) return items.length ? openAt(items, Math.min(currentIndex, items.length - 1)) : finish();
+ currentIndex = index;
+ report(items, index);
+ return true;
+ },
+ previous() {
+ if (!running) return false;
+ const items = queue();
+ const index = items.findIndex(item => workIdentity(item) === currentIdentity);
+ return index > 0 ? openAt(items, index - 1) : false;
+ },
+ next() {
+ if (!running) return false;
+ const items = queue();
+ const index = items.findIndex(item => workIdentity(item) === currentIdentity);
+ return index >= 0 && index < items.length - 1 ? openAt(items, index + 1) : finish();
+ },
+ complete() {
+ if (!running) return false;
+ const items = queue();
+ const stillPresent = items.findIndex(item => workIdentity(item) === currentIdentity);
+ if (stillPresent >= 0) {
+ return stillPresent < items.length - 1 ? openAt(items, stillPresent + 1) : finish();
+ }
+ return items.length ? openAt(items, Math.min(currentIndex, items.length - 1)) : finish();
+ },
+ };
+}
+
function replaceIssueLabels(data, repository, number, labels) {
return {
...data,
@@ -418,6 +496,8 @@ function countMyWork(items) {
if (typeof module !== 'undefined' && module.exports) {
buildMyWork.filterMyWork = filterMyWork;
+ buildMyWork.createWorkSession = createWorkSession;
+ buildMyWork.workIdentity = workIdentity;
buildMyWork.replaceIssueLabels = replaceIssueLabels;
buildMyWork.replaceIssueContent = replaceIssueContent;
buildMyWork.replaceIssueDueDate = replaceIssueDueDate;
diff --git a/tests/test_my_work.py b/tests/test_my_work.py
index 8bb9a06..eac5b53 100644
--- a/tests/test_my_work.py
+++ b/tests/test_my_work.py
@@ -345,6 +345,100 @@ process.stdout.write(JSON.stringify({{
assert output["summary"] == "1 review · 2 assigned"
+def test_mobile_work_session_follows_filter_and_reconciles_by_identity():
+ items = [
+ {"kind": "issue", "repository": "stackchain/api", "number": 1, "title": "First"},
+ {"kind": "pull", "repository": "stackchain/web", "number": 2, "title": "Second"},
+ {"kind": "issue", "repository": "stackchain/api", "number": 3, "title": "Third"},
+ ]
+ script = f"""
+const buildMyWork = require({json.dumps(str(MY_WORK))});
+let items = {json.dumps(items)};
+let filter = 'issue';
+const opened = [];
+const progress = [];
+let finished = 0;
+const session = buildMyWork.createWorkSession({{
+ getItems: () => items,
+ getFilter: () => filter,
+ onOpen: item => opened.push(item.title),
+ onProgress: state => progress.push(state),
+ onFinish: () => {{ finished += 1; }},
+}});
+session.start();
+items = [items[2], items[1], items[0]];
+session.reconcile();
+session.previous();
+items = items.filter(item => item.number !== 3);
+session.complete();
+items = [];
+session.complete();
+process.stdout.write(JSON.stringify({{opened, progress, finished, active:session.active()}}));
+"""
+ result = subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ )
+ output = json.loads(result.stdout)
+
+ assert output["opened"] == ["First", "Third", "First"]
+ assert output["progress"] == [
+ {"index": 1, "total": 2, "can_previous": False, "can_next": True},
+ {"index": 2, "total": 2, "can_previous": True, "can_next": False},
+ {"index": 1, "total": 2, "can_previous": False, "can_next": True},
+ {"index": 1, "total": 1, "can_previous": False, "can_next": False},
+ ]
+ assert output["finished"] == 1
+ assert output["active"] is False
+
+
+def test_mobile_work_session_completion_finishes_when_current_item_is_last():
+ script = f"""
+const buildMyWork = require({json.dumps(str(MY_WORK))});
+const items = [{{kind:'pull',repository:'stackchain/api',number:9,is_review:true}}];
+let finished = 0;
+const session = buildMyWork.createWorkSession({{
+ getItems: () => items, getFilter: () => 'review', onOpen: () => {{}},
+ onProgress: () => {{}}, onFinish: () => {{ finished += 1; }},
+}});
+session.start();
+session.complete();
+process.stdout.write(JSON.stringify({{finished,active:session.active()}}));
+"""
+ result = subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ )
+ assert json.loads(result.stdout) == {"finished": 1, "active": False}
+
+
+@pytest.mark.anyio
+async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sheet():
+ html = await dashboard()
+
+ assert 'id="start-work-session"' in html
+ assert html.count('class="work-session-nav"') == 4
+ assert html.count('') == 4
+ assert html.count('