Patch folder realtime threads locally
This commit is contained in:
@@ -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 "";
|
||||
|
||||
@@ -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 ?? "")),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface BossEventPayload {
|
||||
status?: string;
|
||||
note?: string;
|
||||
conversationItem?: unknown;
|
||||
threadConversationItem?: unknown;
|
||||
}
|
||||
|
||||
type BossEventListener = (event: BossEventName, payload: BossEventPayload) => void;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user