diff --git a/frontend/index.html b/frontend/index.html
index cfe8c4f..5fd93c6 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -314,6 +314,7 @@
+
Today is saved on this device.
diff --git a/frontend/workspace-bootstrap.js b/frontend/workspace-bootstrap.js
index 7a56902..e2fc6d1 100644
--- a/frontend/workspace-bootstrap.js
+++ b/frontend/workspace-bootstrap.js
@@ -1,41 +1,75 @@
-async function loadWorkspace({ document, window = null, createLoader = createFeatureLoader }) {
+async function loadWorkspace({
+ document,
+ window = null,
+ createLoader = createFeatureLoader,
+ schedule = callback => setTimeout(callback,750),
+}) {
let cameOnline = false;
let replayed = false;
+ let retryInFlight = null;
const captureOnline = () => { cameOnline = true; };
window?.addEventListener('online', captureOnline);
const status = document.querySelector('#my-work-action-status');
+ const retryButton = document.querySelector('#retry-workspace');
const url = document.querySelector(
'meta[name="stackchain-feature-today-timer"]'
)?.content || '';
- const loader = createLoader({
- document,
- urls: { 'today-timer': url },
- });
- if (status) status.textContent = 'Starting workspace…';
- try {
- await loader.load('today-timer');
- if (status) status.textContent = '';
- return {
- replayOnline(callback) {
- if (replayed) return;
- replayed = true;
- window?.removeEventListener('online', captureOnline);
- if (cameOnline) callback();
- },
+ const urls = {'today-timer':url};
+ const loader = createLoader({document, urls});
+
+ let attempts = 0;
+ const load = () => {
+ if (attempts++) urls['today-timer'] = url + '?retry=' + attempts;
+ return loader.load('today-timer');
+ };
+ const waitForRecovery = () => new Promise(resolve => {
+ if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';
+ if (retryButton) {
+ retryButton.hidden = retryButton.disabled = false;
+ }
+
+ const recover = () => {
+ if (retryInFlight) return retryInFlight;
+ if (retryButton) retryButton.disabled = true;
+ retryInFlight = load().then(() => {
+ window?.removeEventListener('online', recover);
+ resolve();
+ }).catch(() => {
+ if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';
+ if (retryButton) retryButton.disabled = false;
+ }).finally(() => {
+ retryInFlight = null;
+ });
+ return retryInFlight;
};
- } catch (error) {
+
window?.removeEventListener('online', captureOnline);
- if (window?.location?.reload) {
- if (cameOnline) window.location.reload();
- else window.addEventListener('online', () => window.location.reload(), { once: true });
+ window?.addEventListener('online', recover);
+ retryButton?.addEventListener('click', recover);
+ });
+
+ try {
+ await load();
+ } catch {
+ await new Promise(resolve => schedule(resolve));
+ try {
+ await load();
+ } catch {
+ await waitForRecovery();
}
- if (status) {
- status.textContent = window
- ? 'Workspace could not load. Reconnect to retry automatically, or reload now.'
- : 'Workspace could not load. Check your connection, then reload to retry.';
- }
- throw error;
}
+
+ window?.removeEventListener('online', captureOnline);
+ if (retryButton) retryButton.hidden = true;
+ if (status) status.textContent = '';
+ return {
+ replayOnline(callback) {
+ if (replayed) return;
+ replayed = true;
+ window?.removeEventListener('online', captureOnline);
+ if (cameOnline) callback();
+ },
+ };
}
-if (typeof module !== 'undefined' && module.exports) module.exports = loadWorkspace;
\ No newline at end of file
+if (typeof module !== 'undefined' && module.exports) module.exports = loadWorkspace;
diff --git a/tests/e2e/test_mobile_home_bootstrap_release.py b/tests/e2e/test_mobile_home_bootstrap_release.py
index 176d8af..1f76acc 100644
--- a/tests/e2e/test_mobile_home_bootstrap_release.py
+++ b/tests/e2e/test_mobile_home_bootstrap_release.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import os
+import re
import threading
from pathlib import Path
@@ -181,6 +182,59 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
fake_thread.join(timeout=5)
+def test_release_artifact_recovers_a_transient_workspace_request_in_place(tmp_path: Path):
+ archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
+ assert len(archives) == 1, "browser job must download exactly one assembled release archive"
+
+ fake = FakeGiteaServer(("127.0.0.1", 0))
+ fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
+ fake_thread.start()
+ fake_url = f"http://127.0.0.1:{fake.server_port}"
+ attempts: list[str] = []
+
+ try:
+ with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
+ browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
+ page = browser.new_page(viewport={"width": 390, "height": 844})
+ page.goto(origin + "/", wait_until="networkidle")
+
+ def interrupt_once(route):
+ attempts.append(route.request.url)
+ if len(attempts) == 1:
+ route.abort("failed")
+ else:
+ route.continue_()
+
+ page.route(re.compile(r"/feature-today-timer-[^/?]+\.js(?:\?.*)?$"), interrupt_once)
+ page.locator('input[name="device_label"]').fill("Workspace recovery release phone")
+ page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
+ page.locator("#submit-sign-in").click()
+ page.wait_for_url(origin + "/", wait_until="networkidle")
+
+ page.wait_for_timeout(1500)
+ resources = page.evaluate(
+ "performance.getEntriesByType('resource').map(entry => entry.name)"
+ )
+ state = {
+ "attempts": attempts,
+ "resources": resources,
+ "action": page.locator("#my-work-action-status").text_content(),
+ "retry_hidden": page.locator("#retry-workspace").is_hidden(),
+ "work": page.locator("#my-work-status").text_content(),
+ }
+ assert len(attempts) == 2, state
+ assert "2" in (state["work"] or ""), state
+ expect(page.locator("#retry-workspace")).to_be_hidden()
+ expect(page.locator("#my-work-action-status")).not_to_contain_text("Workspace unavailable")
+ assert any("feature-today-timer-" in url and "retry=" in url for url in resources), state
+ assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
+ browser.close()
+ finally:
+ fake.shutdown()
+ fake.server_close()
+ fake_thread.join(timeout=5)
+
+
def test_release_artifact_keeps_mobile_delivery_recovery_single_flight(tmp_path: Path):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
diff --git a/tests/test_workspace_bootstrap.py b/tests/test_workspace_bootstrap.py
index f13c73f..0023e16 100644
--- a/tests/test_workspace_bootstrap.py
+++ b/tests/test_workspace_bootstrap.py
@@ -4,6 +4,8 @@ from pathlib import Path
BOOTSTRAP = Path(__file__).parents[1] / "frontend" / "workspace-bootstrap.js"
+INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
+CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
def run_bootstrap(scenario: str) -> dict:
@@ -35,33 +37,71 @@ console.log(JSON.stringify({requested,status:status.textContent}));
assert result == {"requested": "today-timer:feature-workspace-abc.js", "status": ""}
-def test_workspace_bootstrap_keeps_shell_and_announces_retry_when_feature_fails():
+def test_workspace_bootstrap_recovers_one_transient_failure_in_place():
result = run_bootstrap("""
-const status={textContent:''};
-const document={
- querySelector(selector) {
- if (selector === 'meta[name="stackchain-feature-today-timer"]') return {content:'feature-workspace-abc.js'};
- if (selector === '#my-work-action-status') return status;
- return null;
- }
-};
-const createLoader=()=>({load:async()=>{throw new Error('network failed');}});
-let message='';
-try { await loadWorkspace({document,createLoader}); } catch (error) { message=error.message; }
-console.log(JSON.stringify({message,status:status.textContent}));
-""")
- assert result == {
- "message": "network failed",
- "status": "Workspace could not load. Check your connection, then reload to retry.",
- }
-
-
-def test_workspace_bootstrap_reloads_after_a_failed_offline_load_reconnects():
- result = run_bootstrap("""
-const status={textContent:''}; const listeners={}; let reloads=0;
+const status={textContent:''}; const retry={hidden:true,disabled:false}; let attempts=0;
const document={querySelector(selector) {
if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'};
if (selector === '#my-work-action-status') return status;
+ if (selector === '#retry-workspace') return retry;
+ return null;
+}};
+const createLoader=()=>({load:async()=>{attempts++; if (attempts === 1) throw new Error('brief outage');}});
+const schedule=callback=>{callback();};
+await loadWorkspace({document,createLoader,schedule});
+console.log(JSON.stringify({attempts,status:status.textContent,retryHidden:retry.hidden}));
+""")
+ assert result == {"attempts": 2, "status": "", "retryHidden": True}
+
+
+def test_workspace_bootstrap_offers_single_flight_manual_retry_without_reloading():
+ result = run_bootstrap("""
+const status={textContent:''}; const listeners={}; let reloads=0; let attempts=0;
+const retry={hidden:true,disabled:false,addEventListener(name,callback){listeners[name]=callback;}};
+const document={querySelector(selector) {
+ if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'};
+ if (selector === '#my-work-action-status') return status;
+ if (selector === '#retry-workspace') return retry;
+ return null;
+}};
+const window={location:{reload(){reloads++;}},addEventListener(){},removeEventListener(){}};
+const createLoader=()=>({load:async()=>{attempts++; if (attempts < 3) throw new Error('offline');}});
+const loading=loadWorkspace({document,window,createLoader,schedule:callback=>callback()});
+await new Promise(resolve=>setImmediate(resolve));
+const offered={hidden:retry.hidden,disabled:retry.disabled,status:status.textContent};
+const first=listeners.click(); const second=listeners.click();
+await Promise.all([first,second,loading]);
+console.log(JSON.stringify({attempts,reloads,offered,status:status.textContent,retryHidden:retry.hidden}));
+""")
+ assert result == {
+ "attempts": 3,
+ "reloads": 0,
+ "offered": {
+ "hidden": False,
+ "disabled": False,
+ "status": "Workspace unavailable. Reconnect or retry.",
+ },
+ "status": "",
+ "retryHidden": True,
+ }
+
+
+def test_workspace_retry_control_is_accessible_and_touch_safe():
+ index = INDEX.read_text()
+ css = CSS.read_text()
+
+ assert '' in index
+ assert ".retry-work-route { min-height:44px;" in css
+
+
+def test_workspace_bootstrap_reconnect_retries_in_place_without_reloading():
+ result = run_bootstrap("""
+const status={textContent:''}; const listeners={}; let reloads=0; let attempts=0;
+const retry={hidden:true,disabled:false,addEventListener(){},removeEventListener(){}};
+const document={querySelector(selector) {
+ if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'};
+ if (selector === '#my-work-action-status') return status;
+ if (selector === '#retry-workspace') return retry;
return null;
}};
const window={
@@ -69,39 +109,22 @@ const window={
addEventListener(name,callback) { listeners[name]=callback; },
removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; },
};
-const createLoader=()=>({load:async()=>{throw new Error('offline');}});
-try { await loadWorkspace({document,window,createLoader}); } catch (error) {}
+const createLoader=()=>({load:async()=>{attempts++; if (attempts < 3) throw new Error('offline');}});
+const loading=loadWorkspace({document,window,createLoader,schedule:callback=>callback()});
+await new Promise(resolve=>setImmediate(resolve));
const waiting=Boolean(listeners.online);
if (listeners.online) listeners.online();
-console.log(JSON.stringify({waiting,reloads,status:status.textContent}));
+await loading;
+console.log(JSON.stringify({attempts,waiting,reloads,status:status.textContent}));
""")
assert result == {
+ "attempts": 3,
"waiting": True,
- "reloads": 1,
- "status": "Workspace could not load. Reconnect to retry automatically, or reload now.",
+ "reloads": 0,
+ "status": "",
}
-def test_workspace_bootstrap_reloads_when_reconnect_arrives_before_load_failure():
- result = run_bootstrap("""
-const status={textContent:''}; const listeners={}; let reloads=0;
-const document={querySelector(selector) {
- if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'};
- if (selector === '#my-work-action-status') return status;
- return null;
-}};
-const window={
- location:{reload(){reloads++;}},
- addEventListener(name,callback) { listeners[name]=callback; },
- removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; },
-};
-const createLoader=()=>({load:async()=>{listeners.online(); throw new Error('offline request');}});
-try { await loadWorkspace({document,window,createLoader}); } catch (error) {}
-console.log(JSON.stringify({reloads,waiting:Boolean(listeners.online)}));
-""")
- assert result == {"reloads": 1, "waiting": False}
-
-
def test_workspace_bootstrap_replays_online_event_after_dashboard_registers_handlers():
result = run_bootstrap("""
const status={textContent:''}; const listeners={};