diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 7d3c9a4..fc334d6 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -269,11 +269,8 @@ } return payload; } - const reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage }); - const wrapPreference = createReviewController.createWrapPreference({ - storage: localStorage, - mobile: window.matchMedia('(max-width: 600px)').matches, - }); + let reviewController = null; + let wrapPreference = null; const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage }); const issueAttachmentController = issueAttachment.mount({ input: qs('#issue-attachment'), @@ -438,7 +435,27 @@ }); } if (Object.values(sharedLaunch).some(Boolean)) await ensureIssueCapture(); - const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage }); + const pullWorkflowFeatures = createFeatureLoader({ + document, + urls: { + 'pull-workflow': document.querySelector('meta[name="stackchain-feature-pull-workflow"]')?.content || '', + }, + }); + let pullController = null; + async function ensurePullWorkflow(trigger = null) { + return await pullWorkflowFeatures.run('pull-workflow', { + trigger, status: qs('#my-work-action-status'), retryLabel:'Tap the work card to retry.', + }, () => { + if (!pullController) pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage }); + if (!reviewController) reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage }); + if (!wrapPreference) { + wrapPreference = createReviewController.createWrapPreference({ + storage: localStorage, + mobile: window.matchMedia('(max-width: 600px)').matches, + }); + } + }); + } const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin }); outboxCoordinator.subscribe(() => refreshMyWorkView()); const offlineWorkStore = createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB }); @@ -456,8 +473,13 @@ retry.hidden = status.failed === 0; } const offlineToday = createOfflineToday({ - loadDetail: item => item.is_review ? reviewController.load(item) : - item.kind === 'pull' ? pullController.load(item) : issueController.load(item), + loadDetail: async item => { + if (item.is_review || item.kind === 'pull') { + if (!await ensurePullWorkflow()) throw new Error('Pull workspace is unavailable.'); + return item.is_review ? reviewController.load(item) : pullController.load(item); + } + return issueController.load(item); + }, loadSavedDetail: (login, item) => offlineWorkStore.loadDetail(login, item), saveDetail: (login, item, detail) => offlineWorkStore.saveDetail(login, item, detail), onStatus: renderOfflineTodayStatus, @@ -2588,6 +2610,7 @@ async function openPullSheet(item, trigger, offlineDetail = null) { if (!item) return; + if (!await ensurePullWorkflow(trigger)) return; if (!sameWorkTarget(selectedPull, item)) pullAttachmentController.clear(); qs('#pull-review').inert = false; selectedPull = item; @@ -3144,6 +3167,7 @@ } async function openReviewSheet(item, trigger, cachedDetail = null) { + if (!item || !await ensurePullWorkflow(trigger)) return; selectedReview = item; reviewTrigger = trigger; offlineReview = Boolean(cachedDetail); diff --git a/frontend/feature-loader.js b/frontend/feature-loader.js index 4fa4beb..34f0559 100644 --- a/frontend/feature-loader.js +++ b/frontend/feature-loader.js @@ -36,6 +36,7 @@ function createFeatureLoader({ document, urls, timeoutMs = 10000 }) { async function run(name, elements, callback) { const trigger = elements?.trigger; const status = elements?.status; + const retryLabel = elements?.retryLabel || 'Tap New issue to retry.'; if (trigger) trigger.disabled = true; if (status) status.textContent = 'Loading ' + name.replace(/-/g, ' ') + '…'; try { @@ -45,8 +46,9 @@ function createFeatureLoader({ document, urls, timeoutMs = 10000 }) { return true; } catch (_error) { if (status) { - const label = name === 'issue-capture' ? 'Issue capture' : name.replace(/-/g, ' '); - status.textContent = label + ' could not load. Tap New issue to retry.'; + const label = name === 'issue-capture' ? 'Issue capture' : + name === 'pull-workflow' ? 'Pull workspace' : name.replace(/-/g, ' '); + status.textContent = label + ' could not load. ' + retryLabel; } return false; } finally { diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 1a9815c..849b58b 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -13,7 +13,10 @@ import rjsmin SCRIPT_TAG = re.compile(r'^$', re.MULTILINE) WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js" -FEATURE_SOURCES = {"issue-capture": ("static/create-issue-sheet.js",)} +FEATURE_SOURCES = { + "issue-capture": ("static/create-issue-sheet.js",), + "pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"), +} CACHE_DECLARATION = re.compile( r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';" ) diff --git a/tests/test_feature_loader.py b/tests/test_feature_loader.py index bcf8857..965d95f 100644 --- a/tests/test_feature_loader.py +++ b/tests/test_feature_loader.py @@ -68,4 +68,24 @@ console.log(JSON.stringify({loading,failedState,first,second,opened,finalStatus: "second": True, "opened": 1, "finalStatus": "", - } \ No newline at end of file + } + + +def test_pull_workspace_failure_is_accessible_and_retryable_from_same_card(): + result = run_loader(""" +const trigger={disabled:false}; const status={textContent:''}; let opened=0; +const loader=createFeatureLoader({document, urls:{'pull-workflow':'feature-pull.js'}, timeoutMs:100}); +const failed=loader.run('pull-workflow',{trigger,status,retryLabel:'Tap the work card to retry.'},()=>{opened++;}); +state.node.onerror(); const first=await failed; +const failedStatus=status.textContent; +const retry=loader.run('pull-workflow',{trigger,status,retryLabel:'Tap the work card to retry.'},()=>{opened++;}); +state.node.onload(); const second=await retry; +console.log(JSON.stringify({first,second,failedStatus,opened,appends:state.appends})); +""") + assert result == { + "first": False, + "second": True, + "failedStatus": "Pull workspace could not load. Tap the work card to retry.", + "opened": 1, + "appends": 2, + } diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index 91aaee7..020e44b 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -42,15 +42,23 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path): assert changed.runtime_bytes != first.runtime_bytes -def test_issue_capture_is_a_stable_lazy_feature_chunk(tmp_path): +def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path): first = build_frontend(FRONTEND) - assert set(first.feature_bundles) == {"issue-capture"} + assert set(first.feature_bundles) == {"issue-capture", "pull-workflow"} capture = first.feature_bundles["issue-capture"] + pull_workflow = first.feature_bundles["pull-workflow"] assert b"function createIssueCapture" not in first.runtime_bytes assert b"function createIssueCapture" in capture.runtime_bytes + assert b"function createPullSheet" not in first.runtime_bytes + assert b"function createReviewController" not in first.runtime_bytes + assert b"function createPullSheet" in pull_workflow.runtime_bytes + assert b"function createReviewController" in pull_workflow.runtime_bytes + assert len(first.runtime_gzip_bytes) <= 95 * 1024 assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html + assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source + assert f"BASE + '{pull_workflow.runtime_name}'" in first.service_worker_source changed_frontend = tmp_path / "frontend" shutil.copytree(FRONTEND, changed_frontend) @@ -61,6 +69,12 @@ def test_issue_capture_is_a_stable_lazy_feature_chunk(tmp_path): assert changed.runtime_name == first.runtime_name assert changed.feature_bundles["issue-capture"].runtime_name != capture.runtime_name + pull_source = changed_frontend / "pull-sheet.js" + pull_source.write_text(pull_source.read_text() + "\n// pull-workflow-only change\n") + pull_changed = build_frontend(changed_frontend) + assert pull_changed.runtime_name == first.runtime_name + assert pull_changed.feature_bundles["pull-workflow"].runtime_name != pull_workflow.runtime_name + @pytest.mark.anyio async def test_feature_chunk_is_immutable_and_rejects_unknown_revision(): diff --git a/tests/test_my_work.py b/tests/test_my_work.py index b98ed82..ecacc41 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -4279,7 +4279,10 @@ async def test_assigned_pulls_open_accessible_mobile_completion_sheet(): assert "pullConversation.append(comment)" in html assert 'id="pull-comment"' in html and 'maxlength="10000"' in html assert 'id="merge-pull"' in html and 'id="open-pull-gitea"' in html - assert '' in html + assert 'stackchain-feature-pull-workflow' in html + assert "pullWorkflowFeatures.run('pull-workflow'" in html + assert "let pullController = null" in html + assert "let reviewController = null" in html assert "pullController.load(item)" in html assert "createPullSheet.renderFile" in html assert "pullController.toggleReviewed" in html