Patch folder realtime threads locally

This commit is contained in:
kris
2026-04-10 22:01:21 +08:00
parent 0781a56aad
commit a084688e35
7 changed files with 145 additions and 0 deletions

View File

@@ -32,6 +32,7 @@ public class ConversationFolderActivity extends BossScreenActivity {
private ArrayList<String> targetProjectIds;
private String targetProjectLabel;
private @Nullable BossRealtimeClient realtimeClient;
private @Nullable JSONObject currentFolderPayload;
private final Handler uiHandler = new Handler(Looper.getMainLooper());
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
private final Set<String> 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 "";

View File

@@ -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 ?? "")),
};
}

View File

@@ -22,6 +22,7 @@ export interface BossEventPayload {
status?: string;
note?: string;
conversationItem?: unknown;
threadConversationItem?: unknown;
}
type BossEventListener = (event: BossEventName, payload: BossEventPayload) => void;

View File

@@ -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,

View File

@@ -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",
);
});

View File

@@ -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();

View File

@@ -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 () => {