diff --git a/android/app/src/main/java/com/hyzq/boss/MainActivity.java b/android/app/src/main/java/com/hyzq/boss/MainActivity.java index 9fc348d..b494b95 100644 --- a/android/app/src/main/java/com/hyzq/boss/MainActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/MainActivity.java @@ -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; diff --git a/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java b/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java index 6683955..daab70c 100644 --- a/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java +++ b/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java @@ -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 items) { List 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", ""); diff --git a/src/app/api/v1/events/route.ts b/src/app/api/v1/events/route.ts index df8d2ee..45093c1 100644 --- a/src/app/api/v1/events/route.ts +++ b/src/app/api/v1/events/route.ts @@ -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) { + 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(() => { diff --git a/src/lib/boss-events.ts b/src/lib/boss-events.ts index e335df6..80a4fe3 100644 --- a/src/lib/boss-events.ts +++ b/src/lib/boss-events.ts @@ -21,6 +21,7 @@ export interface BossEventPayload { taskId?: string; status?: string; note?: string; + conversationItem?: unknown; } type BossEventListener = (event: BossEventName, payload: BossEventPayload) => void; diff --git a/src/lib/boss-projections.ts b/src/lib/boss-projections.ts index c244baf..552df1f 100644 --- a/src/lib/boss-projections.ts +++ b/src/lib/boss-projections.ts @@ -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, diff --git a/tests/conversation-home-items.test.ts b/tests/conversation-home-items.test.ts index 204787f..9d6b0d9 100644 --- a/tests/conversation-home-items.test.ts +++ b/tests/conversation-home-items.test.ts @@ -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(); diff --git a/tests/conversation-realtime-patch.test.ts b/tests/conversation-realtime-patch.test.ts new file mode 100644 index 0000000..ba73d87 --- /dev/null +++ b/tests/conversation-realtime-patch.test.ts @@ -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", + ); +});