Patch conversation home from realtime events

This commit is contained in:
kris
2026-04-10 19:21:36 +08:00
parent 7131ee9eb1
commit 0781a56aad
7 changed files with 255 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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