Compare commits

...
Author SHA1 Message Date
timmy 7948d2e353 feat: file mobile Drafts sequentially (Closes #629)
CI / lint (pull_request) Successful in 1m29s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 07:18:02 +00:00
timmy 1079ec6561 Merge pull request 'Unify mobile work queues in a stable dock switcher' (#628) from timmy/627-mobile-queue-switcher into main
CI / lint (push) Successful in 1m41s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 06:29:46 +00:00
timmy 8d142c808b feat: unify mobile work queues (Closes #627)
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 06:27:00 +00:00
timmy 776ae70019 Merge pull request 'Preserve mobile Drafts when attachment cleanup fails' (#626) from timmy/625-draft-mutation-rollback into main
CI / lint (push) Successful in 1m22s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 05:56:46 +00:00
11 changed files with 344 additions and 52 deletions

No files matched your search

+14 -1
View File
@@ -211,6 +211,10 @@ textarea { resize: vertical; min-height: 120px; }
.create-issue-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); }
.create-issue-actions button { min-height:44px; max-width:100%; width:100%; }
.create-issue-actions #create-issue-status { grid-column:1/-1; }
.draft-filing-session { position:sticky; top:0; z-index:2; display:grid; gap:6px; margin-bottom:12px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:12px; background:rgba(11,21,38,.98); }
.draft-filing-session[hidden] { display:none; }
.draft-filing-session-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.draft-filing-session-actions button { min-height:44px; width:100%; }
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
@@ -587,7 +591,16 @@ textarea { resize: vertical; min-height: 120px; }
.composer-keyboard-active .pull-comment-composer,
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
.mobile-task-dock[data-attention="true"] { grid-template-columns:repeat(6,minmax(0,1fr)); }
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
.mobile-queue-sheet::backdrop { background:rgba(3,9,18,.7); }
.mobile-queue-panel { padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
.mobile-queue-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.mobile-queue-panel h2 { margin:0; }
.mobile-queue-list { display:grid; gap:8px; margin-top:12px; }
.mobile-queue-list button { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:56px; width:100%; padding:10px 14px; text-align:left; }
.mobile-queue-list button > span:first-child { display:grid; gap:2px; }
.mobile-queue-list small { color:var(--muted); }
.mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; }
.mobile-today-hud { position:fixed; left:8px; right:8px; bottom:calc(56px + env(safe-area-inset-bottom)); z-index:44; display:grid; grid-template-columns:minmax(0,1fr) minmax(112px,auto); grid-template-areas:"summary complete" "progress toggle"; gap:4px 8px; max-width:100%; padding:8px; border:1px solid #31577f; border-radius:12px 12px 0 0; background:rgba(16,38,65,.98); box-shadow:0 -8px 24px rgba(0,0,0,.28); }
.mobile-today-summary { grid-area:summary; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-weight:700; }
.mobile-today-hud [data-work-session-progress] { grid-area:progress; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+37 -28
View File
@@ -55,11 +55,11 @@
qs('#my-work').scrollIntoView({block:'start'});
qs('#my-work').focus();
}
function openMobileAttention() {
if (workSession.checkpointed()) timer.beginAttention();
function openMobileQueue(name) {
if (name === 'attention' && workSession.checkpointed()) timer.beginAttention();
renderAttentionInterruption();
timerView.render();
qs('[data-work-filter="attention"]').click();
qs('[data-work-filter="' + name + '"]').click();
qs('#my-work').scrollIntoView({block:'start'});
qs('#my-work').focus();
}
@@ -79,15 +79,23 @@
sessionHud: qs('[data-mobile-today-hud]'),
buttons: mobileTaskButtons,
workLabel: qs('#mobile-work-label'),
attentionBadge: qs('#mobile-attention-count'),
queueSheet: qs('#mobile-queue-sheet'),
queueClose: qs('#close-mobile-queues'),
queueRows: Object.fromEntries(
Array.from(document.querySelectorAll('[data-mobile-queue]')).map(button => [button.dataset.mobileQueue, button])
),
queueCounts: Object.fromEntries(
Array.from(document.querySelectorAll('[data-mobile-queue-count]')).map(element => [element.dataset.mobileQueueCount, element])
),
queueBadge: qs('#mobile-queue-count'),
onSelectQueue: openMobileQueue,
overlays: mobileTaskOverlays,
actions: {
work: () => mobileWorkEntry.open(),
attention: openMobileAttention,
find: () => qs('#find-work').click(),
new: () => qs('#new-issue').click(),
search: () => qs('#open-palette').click(),
drafts: () => qs('[data-work-filter="draft"]').click(),
queues: () => {},
},
observe(callback, overlays) {
const observer = new MutationObserver(callback);
@@ -96,14 +104,6 @@
},
});
mobileTaskDock.start();
const sourceDraftCount = qs('[data-work-count="draft"]');
const draftCount = qs('#mobile-draft-count');
function syncMobileDraftCount() {
draftCount.textContent = sourceDraftCount.textContent;
draftCount.setAttribute('aria-label', sourceDraftCount.textContent + ' drafts');
}
new MutationObserver(syncMobileDraftCount).observe(sourceDraftCount, {childList:true, characterData:true, subtree:true});
syncMobileDraftCount();
let liveMode = true;
let offlineWorkMode = false;
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
@@ -407,7 +407,13 @@
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
getCurrentLogin: () => activeFlushLogin,
});
let resumedUnfiledCaptureId = '';
const dFS = createDraftFilingSession({list:()=>unfiledCaptures.list().filter(item=>!item.quarantined)});
dFS.attach(qs, {
captures:unfiledCaptures, issueCapture, attachment:createIssueAttachmentController,
getLogin:()=>activeFlushLogin, setResumedId:id=>{ rUC = id; },
openSheet:openCreateIssueSheet, setFilingMode:setIssueFilingMode,
});
let rUC = '';
let backgroundIssueSync = null;
if ('indexedDB' in window) {
const backgroundIssueStore = createIssueSyncStore();
@@ -1989,6 +1995,7 @@
const element = qs('[data-work-count="' + filter + '"]');
if (element) element.textContent = count;
});
mobileTaskDock.updateQueues(counts);
mobileTaskDock.updateWork(mobileWorkEntry.mode());
mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention);
const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]');
@@ -2117,12 +2124,8 @@
if (!item) return;
if (item.kind === 'unfiled-issue') {
try {
const resumed = await unfiledCaptures.resume(item.capture_id, activeFlushLogin);
issueCapture.saveDraft(resumed);
resumedUnfiledCaptureId = item.capture_id;
await openCreateIssueSheet();
if (resumed.attachment) createIssueAttachmentController.restore(resumed.attachment);
qs('#create-issue-status').textContent = 'Capture restored. Choose a repository to file it.';
dFS.start(item.capture_id);
await dFS.nextCapture();
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
} else if (item.kind === 'new-issue') openCreateIssueSheet();
else if (item.route) workRoute.open(item.route);
@@ -4052,6 +4055,7 @@
}
});
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
dFS.bind();
qs('#file-new-issue').addEventListener('click', () => {
if (!qs('#create-issue-title').value.trim()) {
qs('#create-issue-capture-status').textContent = 'Add a title before filing.';
@@ -4072,9 +4076,9 @@
};
if (showDraftCapacityDialog(unfiledCaptures)) return;
const savedCapture = await unfiledCaptures.save(captureDraft);
if (resumedUnfiledCaptureId) {
await unfiledCaptures.completeResume(resumedUnfiledCaptureId);
resumedUnfiledCaptureId = '';
if (rUC) {
await unfiledCaptures.completeResume(rUC);
rUC = '';
}
issueCapture.clearDraft();
qs('#create-issue-title').value = '';
@@ -4082,7 +4086,7 @@
createIssueAttachmentController.clear();
closeCreateIssueSheet(true, false);
qs('[data-work-filter="draft"]').click();
mobileTaskDock.select('drafts');
mobileTaskDock.select('queues');
refreshMyWorkView();
const savedCard = qs('[data-capture-id="' + CSS.escape(savedCapture.id) + '"]');
requestAnimationFrame(() => {
@@ -4221,9 +4225,14 @@
const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
await issueOutbox.enqueueDurably(durableDraft);
const queued = admission.item;
if (resumedUnfiledCaptureId && (!durableDraft.attachment || admission.background)) {
await unfiledCaptures.completeResume(resumedUnfiledCaptureId);
resumedUnfiledCaptureId = '';
const fS = dFS.current();
if (rUC && (!durableDraft.attachment || admission.background)) {
await unfiledCaptures.completeResume(rUC);
rUC = '';
}
if (fS && !rUC && await dFS.advance(fS.id)) {
refreshMyWorkView();
return;
}
if (!admission.background) {
editingOutboxId = queued.id;
+2 -2
View File
@@ -22,7 +22,7 @@ function bindDraftCapacityDialog() {
qs('#draft-capacity-sheet').hidden = true;
closeCreateIssueSheet(true, false);
qs('[data-work-filter="draft"]').click();
mobileTaskDock.select('drafts');
mobileTaskDock.select('queues');
refreshMyWorkView();
requestAnimationFrame(() => {
const card = qs('[data-capture-id="' + CSS.escape(oldestId) + '"]');
@@ -52,7 +52,7 @@ function bindDraftCapacityDialog() {
createIssueAttachmentController.clear();
closeCreateIssueSheet(true, false);
qs('[data-work-filter="draft"]').click();
mobileTaskDock.select('drafts');
mobileTaskDock.select('queues');
refreshMyWorkView();
requestAnimationFrame(() => qs('[data-capture-id="' + CSS.escape(saved.id) + '"]')?.focus());
qs('#my-work-action-status').textContent = 'Oldest Draft replaced. New work saved to Drafts.';
+92
View File
@@ -0,0 +1,92 @@
function createDraftFilingSession({list}) {
let orderedIds = [];
let currentId = '';
function visibleIds() {
const available = new Set(list().map(item => item.id));
orderedIds = orderedIds.filter(id => available.has(id));
for (const item of list()) {
if (!orderedIds.includes(item.id)) orderedIds.push(item.id);
}
return orderedIds;
}
function state() {
const ids = visibleIds();
if (!ids.length) {
currentId = '';
return null;
}
if (!ids.includes(currentId)) currentId = ids[0];
const index = ids.indexOf(currentId);
return {id:currentId, position:index + 1, total:ids.length, remaining:ids.length - index - 1};
}
function start(id) {
orderedIds = list().map(item => item.id);
if (!orderedIds.includes(id)) throw new Error('This capture is no longer available.');
currentId = id;
return state();
}
function move(offset) {
const ids = visibleIds();
if (!ids.length) return state();
const index = Math.max(0, ids.indexOf(currentId));
currentId = ids[(index + offset + ids.length) % ids.length];
return state();
}
function removeCurrentAndNext(removedId) {
const previousIds = orderedIds.slice();
const removedIndex = Math.max(0, previousIds.indexOf(removedId));
orderedIds = previousIds.filter(id => id !== removedId);
const ids = visibleIds();
if (!ids.length) return state();
currentId = ids[Math.min(removedIndex, ids.length - 1)];
return state();
}
const session = {start, current:state, next:()=>move(1), previous:()=>move(-1), removeCurrentAndNext};
session.attach = (qs, dependencies) => {
const openCapture = async captureId => {
const resumed = await dependencies.captures.resume(captureId, dependencies.getLogin());
dependencies.issueCapture.saveDraft(resumed);
dependencies.setResumedId(captureId);
await dependencies.openSheet();
dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment);
dependencies.setFilingMode(true);
session.render();
};
return Object.assign(session, {
nextCapture() { const state = session.current(); return state ? openCapture(state.id) : null; },
render() {
const state = session.current();
qs('#draft-filing-session').hidden = !state;
if (!state) return;
qs('#draft-filing-progress').textContent = `Draft ${state.position} of ${state.total}`;
qs('#draft-filing-remaining').textContent = state.remaining ? `${state.remaining} remaining` : 'Last Draft';
qs('#previous-draft').disabled = qs('#skip-draft').disabled = state.total < 2;
qs('#submit-new-issue').textContent = state.remaining ? 'File & next' : 'File final Draft';
},
bind() {
[['#previous-draft','previous'],['#skip-draft','next']].forEach(([selector, method]) =>
qs(selector).addEventListener('click', async () => {
const state = session[method]();
if (state) await openCapture(state.id);
})
);
},
async advance(completedId) {
const state = session.removeCurrentAndNext(completedId);
if (state) { await openCapture(state.id); return true; }
qs('#draft-filing-session').hidden = true;
qs('#submit-new-issue').textContent = 'Create & assign to me';
return false;
},
});
};
return session;
}
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftFilingSession;
+23 -2
View File
@@ -550,6 +550,14 @@
<div class="small" id="create-issue-attachment-status" aria-live="polite"></div>
</section>
<section class="create-issue-filing" id="create-issue-filing" hidden>
<aside class="draft-filing-session" id="draft-filing-session" aria-live="polite" hidden>
<strong id="draft-filing-progress"></strong>
<span class="small" id="draft-filing-remaining"></span>
<div class="draft-filing-session-actions">
<button id="previous-draft" type="button">Previous</button>
<button id="skip-draft" type="button">Skip</button>
</div>
</aside>
<div class="create-issue-repository-picker">
<label for="create-issue-repository-search">Repository search
<input id="create-issue-repository-search" type="search" maxlength="80" placeholder="Search accessible repositories" autocomplete="off" />
@@ -838,13 +846,25 @@
<button data-mobile-today-toggle data-work-session-timer-toggle type="button">Pause timer</button>
<button data-work-session-adjust-plan type="button" hidden>Adjust remaining plan</button>
</section>
<dialog class="mobile-queue-sheet" id="mobile-queue-sheet" aria-labelledby="mobile-queue-heading">
<section class="mobile-queue-panel">
<header><h2 id="mobile-queue-heading">Work queues</h2><button id="close-mobile-queues" type="button">Close</button></header>
<p class="small muted">Choose what to work through next.</p>
<div class="mobile-queue-list">
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
</div>
</section>
</dialog>
<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page"><span id="mobile-work-label">Work</span></button>
<button class="mobile-task-action" data-mobile-task="attention" type="button" aria-label="Attention, 0 items" hidden>Attention <span class="mobile-task-count" id="mobile-attention-count" hidden>0</span></button>
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
<button class="mobile-task-action" data-mobile-task="new" type="button">New</button>
<button class="mobile-task-action" data-mobile-task="search" type="button">Search</button>
<button class="mobile-task-action" data-mobile-task="drafts" type="button">Drafts <span class="mobile-task-count" id="mobile-draft-count" aria-label="0 drafts">0</span></button>
<button class="mobile-task-action" data-mobile-task="queues" type="button" aria-label="Queues, 0 items">Queues <span class="mobile-task-count" id="mobile-queue-count" hidden>0</span></button>
</nav>
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
@@ -858,6 +878,7 @@
<script src="static/widgets.js"></script>
<script src="static/drafts.js"></script>
<script src="static/unfiled-captures.js"></script>
<script src="static/draft-filing-session.js"></script>
<script src="static/draft-capacity-dialog.js"></script>
<script src="static/outbox-coordinator.js"></script>
<script src="static/background-issue-sync.js"></script>
+41 -3
View File
@@ -18,7 +18,7 @@
}
function refreshVisibility() {
const hidden = overlays.some(overlay => overlay.classList.contains('open'));
const hidden = overlays.some(overlay => overlay.open || overlay.classList.contains('open'));
nav.hidden = hidden;
if (options.sessionHud) {
if (hidden) options.sessionHud.setAttribute('data-overlay-hidden', 'true');
@@ -28,14 +28,38 @@
wasHidden = hidden;
}
function openQueues() {
if (!options.queueSheet || options.queueSheet.open) return;
options.queueSheet.showModal();
}
function closeQueues() {
if (options.queueSheet?.open) options.queueSheet.close();
}
function start() {
Object.entries(buttons).forEach(([name, button]) => {
button.addEventListener('click', event => {
launcher = event.currentTarget;
select(name);
options.actions[name]();
if (name === 'queues' && options.queueSheet) openQueues();
else options.actions[name]();
});
});
if (options.queueSheet) {
options.queueClose?.addEventListener('click', closeQueues);
options.queueSheet.addEventListener('cancel', event => {
event.preventDefault();
closeQueues();
});
options.queueSheet.addEventListener('close', () => buttons.queues?.focus());
Object.entries(options.queueRows || {}).forEach(([name, row]) => {
row.addEventListener('click', () => {
closeQueues();
options.onSelectQueue?.(name);
});
});
}
options.observe(refreshVisibility, overlays);
refreshVisibility();
}
@@ -70,5 +94,19 @@
else nav.removeAttribute('data-attention');
}
return {start, select, refreshVisibility, updateAttention, updateWork};
function updateQueues(counts) {
const names = ['today', 'attention', 'later', 'draft'];
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
const total = names.reduce((sum, name) => sum + normalized[name], 0);
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
element.textContent = String(normalized[name] || 0);
});
if (options.queueBadge) {
options.queueBadge.textContent = String(total);
options.queueBadge.hidden = total === 0;
}
if (buttons.queues) buttons.queues.setAttribute('aria-label', 'Queues, ' + total + ' items');
}
return {start, select, refreshVisibility, updateAttention, updateQueues, updateWork};
});
+1
View File
@@ -21,6 +21,7 @@ const SHELL = [
BASE + 'static/widgets.js',
BASE + 'static/drafts.js',
BASE + 'static/unfiled-captures.js',
BASE + 'static/draft-filing-session.js',
BASE + 'static/draft-capacity-dialog.js',
BASE + 'static/outbox-coordinator.js',
BASE + 'static/issue-outbox.js',
+2 -1
View File
@@ -29,7 +29,8 @@ FEATURE_SOURCES = {
"security-center": ("static/security-center.js",),
"today-timer": (
"static/mobile-task-dock.js", "static/today-timer.js", "static/today-recap.js",
"static/today-rollover.js", "static/unfiled-captures.js", "static/draft-capacity-dialog.js",
"static/today-rollover.js", "static/drafts.js", "static/unfiled-captures.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js",
),
}
CACHE_DECLARATION = re.compile(
+84 -10
View File
@@ -274,6 +274,82 @@ process.stdout.write(JSON.stringify({{
}
def test_mobile_task_dock_opens_stable_queue_switcher_and_routes_each_queue():
script = f"""
const createDock = require({json.dumps(str(DOCK))});
class FakeElement {{
constructor() {{ this.listeners = {{}}; this.attributes = {{}}; this.hidden = false; this.textContent = ''; this.focuses = 0; this.classList = {{contains:()=>false}}; }}
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
click() {{ return this.listeners.click?.({{currentTarget:this}}); }}
setAttribute(name, value) {{ this.attributes[name] = value; }}
removeAttribute(name) {{ delete this.attributes[name]; }}
focus() {{ this.focuses += 1; }}
}}
const nav = new FakeElement();
const queues = new FakeElement();
const sheet = new FakeElement(); sheet.open = false;
sheet.showModal = function () {{ this.open = true; }};
sheet.close = function () {{ this.open = false; this.listeners.close?.(); }};
const close = new FakeElement();
const badge = new FakeElement();
const rows = Object.fromEntries(['today','attention','later','draft'].map(name => [name, new FakeElement()]));
const counts = Object.fromEntries(Object.keys(rows).map(name => [name, new FakeElement()]));
const selected = [];
const dock = createDock({{
nav, buttons:{{queues}}, actions:{{queues:()=>{{}}}}, overlays:[sheet],
queueSheet:sheet, queueClose:close, queueRows:rows, queueCounts:counts, queueBadge:badge,
onSelectQueue:name=>selected.push(name),
observe() {{}},
}});
dock.start();
dock.updateQueues({{today:2, attention:1, later:3, draft:4}});
queues.click();
const opened = sheet.open;
rows.later.click();
const closedAfterSelect = !sheet.open;
queues.click(); close.click();
process.stdout.write(JSON.stringify({{
opened, closedAfterSelect, selected,
badge:badge.textContent, badgeLabel:queues.attributes['aria-label'],
counts:Object.fromEntries(Object.entries(counts).map(([name, node]) => [name, node.textContent])),
queueFocuses:queues.focuses,
columnState:nav.attributes['data-attention'] || null,
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"opened": True,
"closedAfterSelect": True,
"selected": ["later"],
"badge": "10",
"badgeLabel": "Queues, 10 items",
"counts": {"today": "2", "attention": "1", "later": "3", "draft": "4"},
"queueFocuses": 2,
"columnState": None,
}
@pytest.mark.anyio
async def test_dashboard_renders_and_wires_mobile_queue_switcher():
html = await dashboard()
assert 'data-mobile-task="queues"' in html
assert 'id="mobile-queue-sheet"' in html
assert 'aria-labelledby="mobile-queue-heading"' in html
assert 'data-mobile-queue="today"' in html
assert 'data-mobile-queue="attention"' in html
assert 'data-mobile-queue="later"' in html
assert 'data-mobile-queue="draft"' in html
assert 'id="mobile-queue-count"' in html
assert 'mobileTaskDock.updateQueues(counts)' in html
assert "onSelectQueue: openMobileQueue" in html
assert '.mobile-queue-sheet' in html
assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html
assert '.mobile-task-dock[data-attention="true"]' not in html
@pytest.mark.anyio
async def test_dashboard_wires_mobile_work_dock_into_today_session_lifecycle():
html = await dashboard()
@@ -301,27 +377,25 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
html = await dashboard()
assert '<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">' in html
assert html.count('class="mobile-task-action" data-mobile-task=') == 6
for task in ("work", "attention", "find", "new", "search", "drafts"):
assert html.count('class="mobile-task-action" data-mobile-task=') == 5
for task in ("work", "find", "new", "search", "queues"):
assert f'data-mobile-task="{task}"' in html
assert 'id="mobile-draft-count"' in html
assert 'id="mobile-queue-count"' in html
assert 'data-work-filter="attention"' in html
assert 'id="mobile-attention-count"' in html
assert '.mobile-task-dock { display:none;' in html
assert 'grid-template-columns:repeat(5,minmax(0,1fr))' in html
assert '.mobile-task-dock[data-attention="true"] { grid-template-columns:repeat(6,minmax(0,1fr));' in html
assert 'padding-bottom:env(safe-area-inset-bottom)' in html
assert '.mobile-task-action { min-width:0; min-height:44px;' in html
assert '<script src="static/mobile-task-dock.js"></script>' in html
assert "createMobileTaskDock({" in html
assert "work: () => mobileWorkEntry.open()" in html
assert "attention: openMobileAttention" in html
assert "qs('[data-work-filter=\"attention\"]').click()" in html
assert "onSelectQueue: openMobileQueue" in html
assert "qs('[data-work-filter=\"' + name + '\"]').click()" in html
assert "find: () => qs('#find-work').click()" in html
assert "new: () => qs('#new-issue').click()" in html
assert "search: () => qs('#open-palette').click()" in html
assert "drafts: () => qs('[data-work-filter=\"draft\"]').click()" in html
assert "draftCount.textContent = sourceDraftCount.textContent" in html
assert "queues: () => {}" in html
assert "mobileTaskDock.updateQueues(counts)" in html
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html
assert "mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention)" in html
@@ -358,7 +432,7 @@ async def test_mobile_attention_pauses_today_and_offers_phone_safe_return():
assert 'class="attention-interruption" id="attention-interruption"' in html
assert 'id="return-to-today" type="button">Return to Today</button>' in html
assert 'if (workSession.checkpointed()) timer.beginAttention();' in html
assert "if (name === 'attention' && workSession.checkpointed()) timer.beginAttention();" in html
assert 'timer.returnFromAttention()' in html
assert "workSession.reopen(item)" in html
assert "attentionInterruption.hidden = !pending" in html
+1
View File
@@ -684,6 +684,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/widgets.js",
"/dashboard/static/drafts.js",
"/dashboard/static/unfiled-captures.js",
"/dashboard/static/draft-filing-session.js",
"/dashboard/static/draft-capacity-dialog.js",
"/dashboard/static/outbox-coordinator.js",
"/dashboard/static/issue-outbox.js",
+47 -5
View File
@@ -8,6 +8,7 @@ from tests.dashboard_bundle import dashboard
UNFILED = Path(__file__).parents[1] / "frontend" / "unfiled-captures.js"
DRAFT_SESSION = Path(__file__).parents[1] / "frontend" / "draft-filing-session.js"
def run_node(script: str):
@@ -15,6 +16,28 @@ def run_node(script: str):
return json.loads(result.stdout)
def test_draft_filing_session_navigates_stable_capture_ids_and_reconciles_removed_items():
script = f"""
const createDraftFilingSession = require({json.dumps(str(DRAFT_SESSION))});
let captures = [{{id:'newest'}}, {{id:'middle'}}, {{id:'oldest'}}];
const session = createDraftFilingSession({{list:()=>captures}});
const opened = session.start('middle');
const skipped = session.next();
captures = [{{id:'newest'}}];
const afterFiled = session.removeCurrentAndNext('oldest');
const previous = session.previous();
process.stdout.write(JSON.stringify({{opened,skipped,afterFiled,previous}}));
"""
output = run_node(script)
assert output == {
"opened": {"id": "middle", "position": 2, "total": 3, "remaining": 1},
"skipped": {"id": "oldest", "position": 3, "total": 3, "remaining": 0},
"afterFiled": {"id": "newest", "position": 1, "total": 1, "remaining": 0},
"previous": {"id": "newest", "position": 1, "total": 1, "remaining": 0},
}
def test_unfiled_captures_block_at_capacity_until_oldest_is_explicitly_replaced():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
@@ -330,18 +353,18 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
assert "getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim()" in html
assert "unfiledCaptures.save(captureDraft)" in html
assert "const savedCapture = await unfiledCaptures.save(captureDraft)" in html
assert "mobileTaskDock.select('drafts')" in html
assert "mobileTaskDock.select('queues')" in html
assert "data-capture-id=\"' + escapeHtml(item.capture_id) + '\"" in html
assert "requestAnimationFrame(() =>" in html
assert "savedCard?.scrollIntoView({block:'nearest'})" in html
assert "Saved to Drafts. Choose a repository when you’re ready to file it." in html
assert "await unfiledCaptures.resume(item.capture_id, activeFlushLogin)" in html
assert "await dependencies.captures.resume(captureId, dependencies.getLogin())" in DRAFT_SESSION.read_text()
assert "await createIssueAttachmentController.serialize()" in html
assert "createUnfiledAttachmentStore()" in html
assert "createIssueAttachmentController.restore(resumed.attachment)" in html
assert "await unfiledCaptures.completeResume(resumedUnfiledCaptureId)" in html
assert "dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment)" in DRAFT_SESSION.read_text()
assert "await unfiledCaptures.completeResume(rUC)" in html
assert "item.hasAttachment ? ' · Screenshot attached' : ''" in html
assert "issueCapture.saveDraft(resumed)" in html
assert "dependencies.issueCapture.saveDraft(resumed)" in DRAFT_SESSION.read_text()
assert "item.kind === 'unfiled-issue'" in html
assert '.create-issue-actions button { min-height:44px;' in html
assert '.draft-card:focus-visible' in html
@@ -349,6 +372,25 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
assert '@media(max-width:320px)' in html
@pytest.mark.anyio
async def test_mobile_drafts_expose_a_safe_sequential_filing_session():
html = await dashboard()
assert '<script src="static/draft-filing-session.js"></script>' in html
assert 'id="draft-filing-session"' in html
assert 'id="draft-filing-progress"' in html
assert 'id="previous-draft"' in html
assert 'id="skip-draft"' in html
assert "dFS.start(item.capture_id)" in html
assert "await dFS.nextCapture()" in html
assert "await dFS.advance(fS.id)" in html
feature = DRAFT_SESSION.read_text()
assert "Draft ${state.position} of ${state.total}" in feature
assert "qs('#submit-new-issue').textContent = state.remaining ? 'File & next' : 'File final Draft'" in feature
assert '.draft-filing-session-actions button { min-height:44px;' in html
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
@pytest.mark.anyio
async def test_mobile_capture_capacity_requires_an_explicit_preserving_decision():
html = await dashboard()