feat: load update conversations progressively (Closes #761)
CI / lint (pull_request) Successful in 1m38s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-13 18:25:21 +00:00
1 parent 7c86f28346
commit a9d586e689
8 files changed
+123 -33

No files matched your search

+1 -13
View File
@@ -284,7 +284,6 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
"http://127.0.0.1:3000/api/v1/notifications/threads/42",
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7",
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9",
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/comments?limit=20&page=1",
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/subscriptions/check",
]
assert result == {
@@ -304,18 +303,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
"issue": {"number": 7, "assignees": [], "claimable": True},
"acknowledge_supported": True,
"mute_supported": True,
"conversation": {
"comments": [{
"id": 9,
"author": "alexander",
"body": "Logs point to the worker timeout.",
"created_at": "2026-08-06T12:30:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
}],
"page": 1,
"older_page": None,
"total": 1,
},
"conversation_available": True,
}
+53 -1
View File
@@ -5169,6 +5169,56 @@ reader.open(firstItem).then(async () => {{
assert output["states"] == [[41], [90]]
def test_notification_reader_renders_core_before_conversation_and_retries_failure_in_place():
script = f"""
const build = require({json.dumps(str(MY_WORK))});
const createPager = require({json.dumps(str(CONVERSATION))});
const events = [];
let attempts = 0;
let release;
const reader = build.createNotificationReader({{
load: async id => ({{id, title:'Core ready', conversation_available:true}}),
createPager,
loadConversation: () => {{
attempts += 1;
if (attempts === 1) return Promise.reject(new Error('comments offline'));
return new Promise(resolve => {{ release = () => resolve({{
comments:[{{id:47, body:'Newest'}}], page:3, older_page:2, total:47,
}}); }});
}},
markRead: async () => {{}}, onOpen: () => events.push('open'),
onDetail: detail => events.push(['detail', detail.title]),
onConversation: state => events.push(['conversation', state.comments.map(item => item.id)]),
onConversationStatus: status => events.push(['conversation-status', status]),
onItems: () => {{}}, onStatus: status => events.push(['status', status]), onClose: () => {{}},
}});
(async () => {{
const opened = await reader.open({{notification_id:42}});
const retrying = reader.retryConversation();
await Promise.resolve();
const beforeRelease = events.slice();
release();
const retried = await retrying;
process.stdout.write(JSON.stringify({{opened, retried, attempts, beforeRelease, events}}));
}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["opened"] is True
assert output["retried"] is True
assert output["attempts"] == 2
assert ["detail", "Core ready"] in output["beforeRelease"]
assert ["status", "Update ready."] in output["beforeRelease"]
assert ["conversation-status", "Conversation temporarily unavailable. Retry."] in output["beforeRelease"]
assert ["conversation", [47]] not in output["beforeRelease"]
assert output["events"][-2:] == [
["conversation", [47]],
["conversation-status", "1 of 47 messages loaded."],
]
def test_notification_reader_hydrates_saved_conversation_without_server_state_actions():
script = f"""
const build = require({json.dumps(str(MY_WORK))});
@@ -7126,7 +7176,9 @@ async def test_mobile_update_sheet_renders_and_pages_the_complete_conversation()
assert 'id="load-older-update-comments"' in update_sheet
assert 'id="update-conversation-status"' in update_sheet
assert '.conversation-more' in html and 'min-height:44px' in html
assert "'/conversation?page='" in html
assert "'/conversation?limit=20' + pageQuery" in html
assert 'id="retry-update-conversation"' in update_sheet
assert "notificationReader.retryConversation()" in html
assert "loadConversation: fetchNotificationConversation" in html
assert "onConversation: renderUpdateConversation" in html
assert "notificationReader.loadOlder()" in html
+22 -9
View File
@@ -7,7 +7,7 @@ from src import gitea_proxy, main
@pytest.mark.anyio
async def test_notification_detail_opens_the_newest_conversation_page_in_chronological_order(monkeypatch):
async def test_notification_detail_returns_core_without_loading_conversation(monkeypatch):
requested_pages = []
def comment(comment_id):
@@ -53,14 +53,9 @@ async def test_notification_detail_opens_the_newest_conversation_page_in_chronol
finally:
await gitea_proxy.stop_client()
assert requested_pages == [1, 3]
assert [item["id"] for item in result["conversation"]["comments"]] == list(range(41, 48))
assert result["conversation"] == {
"comments": result["conversation"]["comments"],
"page": 3,
"older_page": 2,
"total": 47,
}
assert requested_pages == []
assert result["conversation_available"] is True
assert "conversation" not in result
assert result["issue"] == {
"number": 7,
"assignees": [],
@@ -130,6 +125,24 @@ async def test_notification_conversation_api_loads_one_bounded_older_page(monkey
assert calls == [(42, 2, 20)]
@pytest.mark.anyio
async def test_notification_conversation_api_opens_newest_page_when_page_is_omitted(monkeypatch):
calls = []
async def conversation(thread_id, page, limit):
calls.append((thread_id, page, limit))
return {"comments": [{"id": 47}], "page": 3, "older_page": 2, "total": 47}
monkeypatch.setattr(main.gitea_proxy, "notification_conversation_page", conversation)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/notifications/42/conversation?limit=20")
assert response.status_code == 200
assert response.json()["page"] == 3
assert calls == [(42, None, 20)]
@pytest.mark.anyio
async def test_notification_detail_timeout_is_sanitized_and_retryable(monkeypatch):
async def detail(_thread_id):