Patch conversation home from realtime events
This commit is contained in:
@@ -581,9 +581,43 @@ public class MainActivity extends AppCompatActivity {
|
||||
if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
|
||||
return;
|
||||
}
|
||||
if ("conversations".equals(activeTab) && tryApplyConversationRealtimePatch(event)) {
|
||||
return;
|
||||
}
|
||||
runOnUiThread(this::scheduleRealtimeRefresh);
|
||||
}
|
||||
|
||||
private boolean tryApplyConversationRealtimePatch(BossRealtimeEvent event) {
|
||||
if (event == null || conversationsData == 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()) {
|
||||
return false;
|
||||
}
|
||||
JSONObject conversationItem = event.payload.optJSONObject("conversationItem");
|
||||
if (conversationItem == null) {
|
||||
return false;
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
if (conversationsData == null) {
|
||||
scheduleRealtimeRefresh();
|
||||
return;
|
||||
}
|
||||
conversationsData = WechatSurfaceMapper.mergeConversationHomeItem(
|
||||
conversationsData,
|
||||
conversationItem,
|
||||
affectedProjectId
|
||||
);
|
||||
renderCurrentTab();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private void scheduleRealtimeRefresh() {
|
||||
if (realtimeRefreshScheduled) {
|
||||
return;
|
||||
|
||||
@@ -383,6 +383,35 @@ public final class WechatSurfaceMapper {
|
||||
return sortConversationItems(passthrough);
|
||||
}
|
||||
|
||||
public static JSONArray mergeConversationHomeItem(JSONArray source, JSONObject item, String affectedProjectId) {
|
||||
if (source == null) {
|
||||
return null;
|
||||
}
|
||||
JSONArray merged = new JSONArray();
|
||||
String normalizedAffectedProjectId = affectedProjectId == null ? "" : affectedProjectId.trim();
|
||||
String replacementConversationId = item == null ? "" : item.optString("conversationId", "").trim();
|
||||
String replacementFolderKey = item == null ? "" : item.optString("folderKey", "").trim();
|
||||
for (int index = 0; index < source.length(); index += 1) {
|
||||
JSONObject existing = source.optJSONObject(index);
|
||||
if (existing == null) {
|
||||
continue;
|
||||
}
|
||||
if (shouldReplaceConversationItem(
|
||||
existing,
|
||||
normalizedAffectedProjectId,
|
||||
replacementConversationId,
|
||||
replacementFolderKey
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
merged.put(copyJson(existing));
|
||||
}
|
||||
if (item != null) {
|
||||
merged.put(copyJson(item));
|
||||
}
|
||||
return sortConversationItems(merged);
|
||||
}
|
||||
|
||||
private static JSONObject buildFolderArchiveItem(String folderKey, List<JSONObject> items) {
|
||||
List<JSONObject> sortedByLatest = new ArrayList<>(items);
|
||||
sortedByLatest.sort((left, right) -> compareConversationFreshness(right, left));
|
||||
@@ -488,6 +517,42 @@ public final class WechatSurfaceMapper {
|
||||
return sorted;
|
||||
}
|
||||
|
||||
private static boolean shouldReplaceConversationItem(
|
||||
JSONObject existing,
|
||||
String affectedProjectId,
|
||||
String replacementConversationId,
|
||||
String replacementFolderKey
|
||||
) {
|
||||
if (!replacementConversationId.isEmpty()
|
||||
&& replacementConversationId.equals(existing.optString("conversationId", "").trim())) {
|
||||
return true;
|
||||
}
|
||||
if (!replacementFolderKey.isEmpty()
|
||||
&& replacementFolderKey.equals(existing.optString("folderKey", "").trim())) {
|
||||
return true;
|
||||
}
|
||||
if (affectedProjectId.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (affectedProjectId.equals(existing.optString("projectId", "").trim())) {
|
||||
return true;
|
||||
}
|
||||
return arrayContainsString(existing.optJSONArray("searchTargetProjectIds"), affectedProjectId);
|
||||
}
|
||||
|
||||
private static boolean arrayContainsString(JSONArray source, String target) {
|
||||
if (source == null || target == null || target.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String normalizedTarget = target.trim();
|
||||
for (int index = 0; index < source.length(); index += 1) {
|
||||
if (normalizedTarget.equals(source.optString(index, "").trim())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int compareConversationFreshness(JSONObject left, JSONObject right) {
|
||||
String leftAt = left.optString("latestReplyAt", "");
|
||||
String rightAt = right.optString("latestReplyAt", "");
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { subscribeBossEvents } from "@/lib/boss-events";
|
||||
import { getAuditSummaryView, getConversationItems, getOpsSummaryView } from "@/lib/boss-projections";
|
||||
import { subscribeBossEvents, type BossEventPayload } from "@/lib/boss-events";
|
||||
import {
|
||||
getAuditSummaryView,
|
||||
getConversationHomeItemForProject,
|
||||
getConversationItems,
|
||||
getOpsSummaryView,
|
||||
} from "@/lib/boss-projections";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -11,6 +16,24 @@ function sseEvent(event: string, data: unknown) {
|
||||
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
}
|
||||
|
||||
function shouldEnrichConversationPatch(event: string, payload: Pick<BossEventPayload, "projectId">) {
|
||||
if (!payload.projectId?.trim()) {
|
||||
return false;
|
||||
}
|
||||
return event === "conversation.updated" || event === "project.messages.updated";
|
||||
}
|
||||
|
||||
async function buildEventPayload(event: string, payload: BossEventPayload) {
|
||||
if (!shouldEnrichConversationPatch(event, payload)) {
|
||||
return payload;
|
||||
}
|
||||
const state = await readState();
|
||||
return {
|
||||
...payload,
|
||||
conversationItem: getConversationHomeItemForProject(state, String(payload.projectId ?? "")),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
@@ -46,11 +69,14 @@ export async function GET(request: NextRequest) {
|
||||
await publishSnapshots();
|
||||
|
||||
unsubscribe = subscribeBossEvents((event, payload) => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(sseEvent(event, payload)));
|
||||
} catch {
|
||||
unsubscribe?.();
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const eventPayload = await buildEventPayload(event, payload);
|
||||
controller.enqueue(encoder.encode(sseEvent(event, eventPayload)));
|
||||
} catch {
|
||||
unsubscribe?.();
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
heartbeatTimer = setInterval(() => {
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface BossEventPayload {
|
||||
taskId?: string;
|
||||
status?: string;
|
||||
note?: string;
|
||||
conversationItem?: unknown;
|
||||
}
|
||||
|
||||
type BossEventListener = (event: BossEventName, payload: BossEventPayload) => void;
|
||||
|
||||
@@ -654,6 +654,23 @@ export function getConversationHomeItems(state: BossState): ConversationItem[] {
|
||||
return sortConversationItems(passthrough);
|
||||
}
|
||||
|
||||
export function getConversationHomeItemForProject(state: BossState, projectId: string): ConversationItem | null {
|
||||
const normalizedProjectId = projectId.trim();
|
||||
if (!normalizedProjectId) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
getConversationHomeItems(state).find((item) => {
|
||||
if (item.projectId === normalizedProjectId) {
|
||||
return true;
|
||||
}
|
||||
return Array.isArray(item.searchTargetProjectIds)
|
||||
? item.searchTargetProjectIds.includes(normalizedProjectId)
|
||||
: false;
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function getConversationFolderView(
|
||||
state: BossState,
|
||||
folderKey: string,
|
||||
|
||||
@@ -8,6 +8,7 @@ let runtimeRoot = "";
|
||||
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 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"];
|
||||
@@ -28,6 +29,7 @@ async function setup() {
|
||||
readState = data.readState;
|
||||
updateConversationAction = data.updateConversationAction;
|
||||
getConversationHomeItems = projections.getConversationHomeItems;
|
||||
getConversationHomeItemForProject = projections.getConversationHomeItemForProject;
|
||||
getConversationFolderView = projections.getConversationFolderView;
|
||||
formatTimestampLabel = projections.formatTimestampLabel;
|
||||
getConversationListItemPresentation = ui.getConversationListItemPresentation;
|
||||
@@ -173,6 +175,64 @@ test("folder archives use the latest thread preview/time while subtitle and cont
|
||||
assert.equal(folder?.contextBudgetUpdatedAt, "2026-04-04T11:05:00+08:00");
|
||||
});
|
||||
|
||||
test("conversation home patch lookup returns the visible folder archive item for grouped threads", 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 = getConversationHomeItemForProject(state, "boss-thread-b");
|
||||
|
||||
assert.ok(item, "expected grouped thread lookup to resolve to a visible home item");
|
||||
assert.equal(item?.conversationType, "folder_archive");
|
||||
assert.equal(item?.projectId, "mac-studio:boss");
|
||||
assert.deepEqual(item?.searchTargetProjectIds, ["boss-thread-a", "boss-thread-b"]);
|
||||
});
|
||||
|
||||
test("conversation home patch lookup returns the direct thread item when no folder archive exists", async () => {
|
||||
await setup();
|
||||
const state = await readState();
|
||||
|
||||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||||
state.projects.push(
|
||||
buildImportedThreadProject(
|
||||
"mac-studio",
|
||||
"solo-thread",
|
||||
"Solo",
|
||||
"solo",
|
||||
"单线程",
|
||||
"thread-solo",
|
||||
"2026-04-04T12:00:00+08:00",
|
||||
),
|
||||
);
|
||||
|
||||
const item = getConversationHomeItemForProject(state, "solo-thread");
|
||||
|
||||
assert.ok(item, "expected single thread lookup to resolve to its visible home item");
|
||||
assert.equal(item?.conversationType, "single_device");
|
||||
assert.equal(item?.projectId, "solo-thread");
|
||||
});
|
||||
|
||||
test("folder archive context ring prefers more urgent contextBudgetLevel when mustFinishBeforeCompaction is equal", async () => {
|
||||
await setup();
|
||||
const state = await readState();
|
||||
|
||||
45
tests/conversation-realtime-patch.test.ts
Normal file
45
tests/conversation-realtime-patch.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
async function readSource(path: string) {
|
||||
return readFile(new URL(path, import.meta.url), "utf8");
|
||||
}
|
||||
|
||||
test("events route enriches project conversation events with a visible home item patch", async () => {
|
||||
const source = await readSource("../src/app/api/v1/events/route.ts");
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/getConversationHomeItemForProject/,
|
||||
"expected realtime event route to resolve visible conversation items for project updates",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/conversationItem:\s*getConversationHomeItemForProject\(state,\s*String\(payload\.projectId \?\? ""\)\)/,
|
||||
"expected enriched event payload to carry a conversation item slot",
|
||||
);
|
||||
});
|
||||
|
||||
test("MainActivity applies realtime conversation patches without forcing a network refresh", async () => {
|
||||
const [mainActivity, mapper] = await Promise.all([
|
||||
readSource("../android/app/src/main/java/com/hyzq/boss/MainActivity.java"),
|
||||
readSource("../android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java"),
|
||||
]);
|
||||
|
||||
assert.match(
|
||||
mainActivity,
|
||||
/tryApplyConversationRealtimePatch\(event\)/,
|
||||
"expected root conversation page to try a local realtime patch before falling back to network refresh",
|
||||
);
|
||||
assert.match(
|
||||
mainActivity,
|
||||
/WechatSurfaceMapper\.mergeConversationHomeItem\(/,
|
||||
"expected root conversation page to merge realtime conversation items locally",
|
||||
);
|
||||
assert.match(
|
||||
mapper,
|
||||
/public static JSONArray mergeConversationHomeItem\(/,
|
||||
"expected surface mapper to expose a home-feed merge helper for realtime patches",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user