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