From a084688e35d654fdd4b85d8b41c6440beeb191d0 Mon Sep 17 00:00:00 2001 From: kris Date: Fri, 10 Apr 2026 22:01:21 +0800 Subject: [PATCH] Patch folder realtime threads locally --- .../hyzq/boss/ConversationFolderActivity.java | 83 +++++++++++++++++++ src/app/api/v1/events/route.ts | 2 + src/lib/boss-events.ts | 1 + src/lib/boss-projections.ts | 8 ++ tests/android-folder-realtime-refresh.test.ts | 10 +++ tests/conversation-home-items.test.ts | 36 ++++++++ tests/conversation-realtime-patch.test.ts | 5 ++ 7 files changed, 145 insertions(+) diff --git a/android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java b/android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java index e7a52d8..7c31572 100644 --- a/android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java @@ -32,6 +32,7 @@ public class ConversationFolderActivity extends BossScreenActivity { private ArrayList targetProjectIds; private String targetProjectLabel; private @Nullable BossRealtimeClient realtimeClient; + private @Nullable JSONObject currentFolderPayload; private final Handler uiHandler = new Handler(Looper.getMainLooper()); private final Map recentRealtimeEventTimestamps = new LinkedHashMap<>(); private final Set trackedProjectIds = new LinkedHashSet<>(); @@ -144,9 +145,48 @@ public class ConversationFolderActivity extends BossScreenActivity { if (isDuplicateRealtimeEvent(eventFingerprint, now)) { return; } + if (tryApplyFolderRealtimePatch(event)) { + return; + } runOnUiThread(this::scheduleRealtimeReload); } + private boolean tryApplyFolderRealtimePatch(BossRealtimeEvent event) { + if (event == null || currentFolderPayload == null) { + return false; + } + if (!"conversation.updated".equals(event.eventName) + && !"project.messages.updated".equals(event.eventName)) { + return false; + } + String affectedProjectId = event.payload.optString("projectId", "").trim(); + if (affectedProjectId.isEmpty() || !trackedProjectIds.contains(affectedProjectId)) { + return false; + } + JSONObject threadConversationItem = event.payload.optJSONObject("threadConversationItem"); + if (threadConversationItem == null) { + return false; + } + String patchedFolderKey = threadConversationItem.optString("folderKey", "").trim(); + if (folderKey == null || folderKey.isEmpty() || !folderKey.equals(patchedFolderKey)) { + return false; + } + runOnUiThread(() -> { + if (currentFolderPayload == null) { + scheduleRealtimeReload(); + return; + } + JSONObject mergedFolder = replaceThreadConversationItem( + currentFolderPayload, + affectedProjectId, + threadConversationItem + ); + currentFolderPayload = mergedFolder; + renderFolder(mergedFolder); + }); + return true; + } + private void scheduleRealtimeReload() { if (realtimeReloadScheduled) { return; @@ -201,12 +241,14 @@ public class ConversationFolderActivity extends BossScreenActivity { private void renderFolder(@Nullable JSONObject folder) { replaceContent(); if (folder == null) { + currentFolderPayload = null; trackedProjectIds.clear(); appendContent(BossUi.buildEmptyCard(this, "未找到项目线程。")); setRefreshing(false); return; } + currentFolderPayload = copyJson(folder); String resolvedFolderName = folder.optString("folderLabel", folderName == null ? "项目线程" : folderName); folderDeviceId = folder.optString("deviceId", folderDeviceId == null ? "" : folderDeviceId).trim(); int threadCount = folder.optInt("threadCount", 0); @@ -273,6 +315,47 @@ public class ConversationFolderActivity extends BossScreenActivity { } } + private JSONObject replaceThreadConversationItem(JSONObject folder, String affectedProjectId, JSONObject threadConversationItem) { + JSONObject mergedFolder = copyJson(folder); + JSONArray existingThreads = mergedFolder.optJSONArray("threads"); + JSONArray mergedThreads = new JSONArray(); + boolean replaced = false; + if (existingThreads != null) { + for (int i = 0; i < existingThreads.length(); i++) { + JSONObject item = existingThreads.optJSONObject(i); + if (item == null) { + continue; + } + if (affectedProjectId.equals(item.optString("projectId", "").trim())) { + mergedThreads.put(copyJson(threadConversationItem)); + replaced = true; + continue; + } + mergedThreads.put(copyJson(item)); + } + } + if (!replaced) { + mergedThreads.put(copyJson(threadConversationItem)); + } + try { + mergedFolder.put("threads", mergedThreads); + mergedFolder.put("threadCount", mergedThreads.length()); + } catch (org.json.JSONException ignored) { + } + return mergedFolder; + } + + private JSONObject copyJson(@Nullable JSONObject source) { + if (source == null) { + return new JSONObject(); + } + try { + return new JSONObject(source.toString()); + } catch (org.json.JSONException ignored) { + return new JSONObject(); + } + } + private String parseFolderDeviceId(@Nullable String candidateFolderKey) { if (candidateFolderKey == null) { return ""; diff --git a/src/app/api/v1/events/route.ts b/src/app/api/v1/events/route.ts index 45093c1..e20b390 100644 --- a/src/app/api/v1/events/route.ts +++ b/src/app/api/v1/events/route.ts @@ -5,6 +5,7 @@ import { subscribeBossEvents, type BossEventPayload } from "@/lib/boss-events"; import { getAuditSummaryView, getConversationHomeItemForProject, + getConversationThreadItemForProject, getConversationItems, getOpsSummaryView, } from "@/lib/boss-projections"; @@ -31,6 +32,7 @@ async function buildEventPayload(event: string, payload: BossEventPayload) { return { ...payload, conversationItem: getConversationHomeItemForProject(state, String(payload.projectId ?? "")), + threadConversationItem: getConversationThreadItemForProject(state, String(payload.projectId ?? "")), }; } diff --git a/src/lib/boss-events.ts b/src/lib/boss-events.ts index 80a4fe3..539137d 100644 --- a/src/lib/boss-events.ts +++ b/src/lib/boss-events.ts @@ -22,6 +22,7 @@ export interface BossEventPayload { status?: string; note?: string; conversationItem?: unknown; + threadConversationItem?: unknown; } type BossEventListener = (event: BossEventName, payload: BossEventPayload) => void; diff --git a/src/lib/boss-projections.ts b/src/lib/boss-projections.ts index 552df1f..e4ab12e 100644 --- a/src/lib/boss-projections.ts +++ b/src/lib/boss-projections.ts @@ -671,6 +671,14 @@ export function getConversationHomeItemForProject(state: BossState, projectId: s ); } +export function getConversationThreadItemForProject(state: BossState, projectId: string): ConversationItem | null { + const normalizedProjectId = projectId.trim(); + if (!normalizedProjectId) { + return null; + } + return getConversationItems(state).find((item) => item.projectId === normalizedProjectId) ?? null; +} + export function getConversationFolderView( state: BossState, folderKey: string, diff --git a/tests/android-folder-realtime-refresh.test.ts b/tests/android-folder-realtime-refresh.test.ts index 2a9235b..a89ead3 100644 --- a/tests/android-folder-realtime-refresh.test.ts +++ b/tests/android-folder-realtime-refresh.test.ts @@ -23,4 +23,14 @@ test("android folder activity refreshes on matching device-scoped conversation u /payloadDeviceId\.equals\(folderDeviceId\)/, "expected folder activity to reload when the event targets its device", ); + assert.match( + source, + /tryApplyFolderRealtimePatch\(event\)/, + "expected folder activity to try a local thread-row patch before falling back to network reload", + ); + assert.match( + source, + /threadConversationItem/, + "expected folder activity to read the direct thread item patch from realtime payloads", + ); }); diff --git a/tests/conversation-home-items.test.ts b/tests/conversation-home-items.test.ts index 9d6b0d9..ed29eac 100644 --- a/tests/conversation-home-items.test.ts +++ b/tests/conversation-home-items.test.ts @@ -9,6 +9,7 @@ let readState: (typeof import("../src/lib/boss-data"))["readState"]; let updateConversationAction: (typeof import("../src/lib/boss-data"))["updateConversationAction"]; let getConversationHomeItems: (typeof import("../src/lib/boss-projections"))["getConversationHomeItems"]; let getConversationHomeItemForProject: (typeof import("../src/lib/boss-projections"))["getConversationHomeItemForProject"]; +let getConversationThreadItemForProject: (typeof import("../src/lib/boss-projections"))["getConversationThreadItemForProject"]; let getConversationFolderView: (typeof import("../src/lib/boss-projections"))["getConversationFolderView"]; let formatTimestampLabel: (typeof import("../src/lib/boss-projections"))["formatTimestampLabel"]; let getConversationListItemPresentation: (typeof import("../src/components/app-ui"))["getConversationListItemPresentation"]; @@ -30,6 +31,7 @@ async function setup() { updateConversationAction = data.updateConversationAction; getConversationHomeItems = projections.getConversationHomeItems; getConversationHomeItemForProject = projections.getConversationHomeItemForProject; + getConversationThreadItemForProject = projections.getConversationThreadItemForProject; getConversationFolderView = projections.getConversationFolderView; formatTimestampLabel = projections.formatTimestampLabel; getConversationListItemPresentation = ui.getConversationListItemPresentation; @@ -233,6 +235,40 @@ test("conversation home patch lookup returns the direct thread item when no fold assert.equal(item?.projectId, "solo-thread"); }); +test("conversation thread patch lookup keeps the direct single-thread item for grouped folders", async () => { + await setup(); + const state = await readState(); + + state.projects = state.projects.filter((project) => project.id === "master-agent"); + state.projects.push( + buildImportedThreadProject( + "mac-studio", + "boss-thread-a", + "Boss", + "boss", + "线程 A", + "thread-a", + "2026-04-04T12:00:00+08:00", + ), + buildImportedThreadProject( + "mac-studio", + "boss-thread-b", + "Boss", + "boss", + "线程 B", + "thread-b", + "2026-04-04T11:00:00+08:00", + ), + ); + + const item = getConversationThreadItemForProject(state, "boss-thread-b"); + + assert.ok(item, "expected grouped thread lookup to resolve to its direct thread row"); + assert.equal(item?.conversationType, "single_device"); + assert.equal(item?.projectId, "boss-thread-b"); + assert.equal(item?.folderKey, "mac-studio:boss"); +}); + test("folder archive context ring prefers more urgent contextBudgetLevel when mustFinishBeforeCompaction is equal", async () => { await setup(); const state = await readState(); diff --git a/tests/conversation-realtime-patch.test.ts b/tests/conversation-realtime-patch.test.ts index ba73d87..e269103 100644 --- a/tests/conversation-realtime-patch.test.ts +++ b/tests/conversation-realtime-patch.test.ts @@ -19,6 +19,11 @@ test("events route enriches project conversation events with a visible home item /conversationItem:\s*getConversationHomeItemForProject\(state,\s*String\(payload\.projectId \?\? ""\)\)/, "expected enriched event payload to carry a conversation item slot", ); + assert.match( + source, + /threadConversationItem:\s*getConversationThreadItemForProject\(state,\s*String\(payload\.projectId \?\? ""\)\)/, + "expected enriched event payload to carry a direct thread item slot for folder pages", + ); }); test("MainActivity applies realtime conversation patches without forcing a network refresh", async () => {