From da55071a9995b610ef76d050008dc8acc179739e Mon Sep 17 00:00:00 2001 From: kris Date: Fri, 3 Apr 2026 10:09:19 +0800 Subject: [PATCH] fix: show conversation context status --- .../src/main/java/com/hyzq/boss/BossUi.java | 65 +++++++--- .../com/hyzq/boss/WechatSurfaceMapper.java | 74 ++++++++++- .../hyzq/boss/BossUiConversationRowTest.java | 45 +++++++ ...atSurfaceMapperConversationStatusTest.java | 118 ++++++++++++++++++ src/lib/boss-projections.ts | 63 +++++++--- tests/conversation-home-items.test.ts | 50 ++++++++ 6 files changed, 382 insertions(+), 33 deletions(-) create mode 100644 android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperConversationStatusTest.java diff --git a/android/app/src/main/java/com/hyzq/boss/BossUi.java b/android/app/src/main/java/com/hyzq/boss/BossUi.java index 2fa45b5..4675640 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossUi.java +++ b/android/app/src/main/java/com/hyzq/boss/BossUi.java @@ -675,26 +675,41 @@ public final class BossUi { trailingColumn.addView(unreadView); } - LinearLayout activityWrap = new LinearLayout(context); - activityWrap.setOrientation(LinearLayout.HORIZONTAL); - activityWrap.setGravity(Gravity.END | Gravity.CENTER_VERTICAL); - LinearLayout.LayoutParams activityParams = new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ); - activityParams.topMargin = dp(context, 10); - activityWrap.setLayoutParams(activityParams); int activityCount = Math.max(0, Math.min(row.activityIconCount, WechatSurfaceMapper.maxConversationActivityIcons())); - for (int i = 0; i < activityCount; i++) { - View dot = buildAnimatedActivityDot(context, i); - if (i > 0) { - LinearLayout.LayoutParams dotParams = (LinearLayout.LayoutParams) dot.getLayoutParams(); - dotParams.leftMargin = dp(context, 4); - dot.setLayoutParams(dotParams); + if (activityCount > 0) { + LinearLayout activityWrap = new LinearLayout(context); + activityWrap.setOrientation(LinearLayout.HORIZONTAL); + activityWrap.setGravity(Gravity.END | Gravity.CENTER_VERTICAL); + LinearLayout.LayoutParams activityParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + activityParams.topMargin = dp(context, 8); + activityWrap.setLayoutParams(activityParams); + for (int i = 0; i < activityCount; i++) { + View dot = buildAnimatedActivityDot(context, i); + if (i > 0) { + LinearLayout.LayoutParams dotParams = (LinearLayout.LayoutParams) dot.getLayoutParams(); + dotParams.leftMargin = dp(context, 4); + dot.setLayoutParams(dotParams); + } + activityWrap.addView(dot); } - activityWrap.addView(dot); + trailingColumn.addView(activityWrap); + } + + if (!TextUtils.isEmpty(row.contextStatusLabel)) { + TextView contextView = new TextView(context); + contextView.setText(row.contextStatusLabel); + contextView.setTextSize(11); + contextView.setTypeface(Typeface.DEFAULT_BOLD); + contextView.setTextColor(resolveConversationContextColor(context, row.contextStatusLevel)); + contextView.setPadding(0, dp(context, 8), 0, 0); + contextView.setMaxLines(1); + contextView.setEllipsize(TextUtils.TruncateAt.END); + contextView.setGravity(Gravity.END); + trailingColumn.addView(contextView); } - trailingColumn.addView(activityWrap); card.addView(trailingColumn); return card; @@ -1331,6 +1346,22 @@ public final class BossUi { } } + private static int resolveConversationContextColor(Context context, @Nullable String level) { + if (TextUtils.isEmpty(level)) { + return context.getColor(R.color.boss_text_soft); + } + switch (level) { + case "critical": + return Color.parseColor("#D94B4B"); + case "urgent": + return Color.parseColor("#E0832A"); + case "watch": + return Color.parseColor("#7B8A82"); + default: + return context.getColor(R.color.boss_text_soft); + } + } + private static String firstLetter(String value) { String text = value == null ? "" : value.trim(); if (text.isEmpty()) { 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 3911d75..eb358dd 100644 --- a/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java +++ b/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java @@ -65,7 +65,9 @@ public final class WechatSurfaceMapper { isGroup, isGroup ? "" : avatar == null ? "" : avatar.optString("primary", ""), isGroup ? "" : avatar == null ? "" : avatar.optString("secondary", ""), - groupAvatarMembers.toArray(new GroupAvatarMember[0]) + groupAvatarMembers.toArray(new GroupAvatarMember[0]), + buildContextStatusLabel(source), + resolveContextStatusLevel(source) ); } @@ -163,6 +165,40 @@ public final class WechatSurfaceMapper { return "cancel_on_detach"; } + private static String buildContextStatusLabel(JSONObject source) { + if (source.optBoolean("mustFinishBeforeCompaction", false)) { + return "必须收尾"; + } + JSONObject indicator = source.optJSONObject("contextBudgetIndicator"); + if (indicator == null || !indicator.optBoolean("visible", false)) { + return null; + } + String level = indicator.optString("level", "safe"); + int percent = indicator.optInt("percent", -1); + switch (level) { + case "critical": + return percent >= 0 ? "上下文危急 " + percent + "%" : "上下文危急"; + case "urgent": + return percent >= 0 ? "上下文紧张 " + percent + "%" : "上下文紧张"; + case "watch": + return percent >= 0 ? "上下文关注 " + percent + "%" : "上下文关注"; + default: + return percent >= 0 ? "上下文 " + percent + "%" : "上下文稳定"; + } + } + + private static String resolveContextStatusLevel(JSONObject source) { + if (source.optBoolean("mustFinishBeforeCompaction", false)) { + JSONObject indicator = source.optJSONObject("contextBudgetIndicator"); + return indicator == null ? "critical" : indicator.optString("level", "critical"); + } + JSONObject indicator = source.optJSONObject("contextBudgetIndicator"); + if (indicator == null || !indicator.optBoolean("visible", false)) { + return null; + } + return indicator.optString("level", null); + } + public static RootTopAction rootTopAction(String activeTab, boolean refreshing) { if ("devices".equals(activeTab)) { return new RootTopAction("+添加", true, false, "add_device"); @@ -344,6 +380,8 @@ public final class WechatSurfaceMapper { public final String avatarPrimary; public final String avatarSecondary; public final GroupAvatarMember[] groupAvatarMembers; + public final String contextStatusLabel; + public final String contextStatusLevel; public ConversationRow( String threadTitle, @@ -357,6 +395,38 @@ public final class WechatSurfaceMapper { String avatarPrimary, String avatarSecondary, GroupAvatarMember[] groupAvatarMembers + ) { + this( + threadTitle, + folderLabel, + lastMessagePreview, + timeLabel, + unreadCount, + topPinnedLabel, + activityIconCount, + isGroup, + avatarPrimary, + avatarSecondary, + groupAvatarMembers, + null, + null + ); + } + + public ConversationRow( + String threadTitle, + String folderLabel, + String lastMessagePreview, + String timeLabel, + int unreadCount, + String topPinnedLabel, + int activityIconCount, + boolean isGroup, + String avatarPrimary, + String avatarSecondary, + GroupAvatarMember[] groupAvatarMembers, + String contextStatusLabel, + String contextStatusLevel ) { this.threadTitle = threadTitle; this.folderLabel = folderLabel; @@ -369,6 +439,8 @@ public final class WechatSurfaceMapper { this.avatarPrimary = avatarPrimary; this.avatarSecondary = avatarSecondary; this.groupAvatarMembers = groupAvatarMembers == null ? new GroupAvatarMember[0] : groupAvatarMembers; + this.contextStatusLabel = contextStatusLabel; + this.contextStatusLevel = contextStatusLevel; } } diff --git a/android/app/src/test/java/com/hyzq/boss/BossUiConversationRowTest.java b/android/app/src/test/java/com/hyzq/boss/BossUiConversationRowTest.java index 2f1b738..a36b6c2 100644 --- a/android/app/src/test/java/com/hyzq/boss/BossUiConversationRowTest.java +++ b/android/app/src/test/java/com/hyzq/boss/BossUiConversationRowTest.java @@ -64,4 +64,49 @@ public class BossUiConversationRowTest { assertTrue("预览需要保留可见宽度: " + metrics, previewView.getMeasuredWidth() > 0); assertTrue("右侧信息列不应吞掉中间内容: " + metrics, trailingColumn.getMeasuredWidth() < rowView.getMeasuredWidth() / 2); } + + @Test + public void buildConversationRow_showsContextStatusWithoutIdleActivityDots() { + Context context = RuntimeEnvironment.getApplication(); + WechatSurfaceMapper.ConversationRow row = new WechatSurfaceMapper.ConversationRow( + "北区试产线回归", + "归档确认", + "线程链路已稳定", + "09:26", + 0, + "", + 0, + false, + "M", + "W", + new WechatSurfaceMapper.GroupAvatarMember[0], + "上下文紧张 34%", + "urgent" + ); + + LinearLayout rowView = BossUi.buildConversationRow(context, row, null); + LinearLayout trailingColumn = (LinearLayout) rowView.getChildAt(2); + + assertTrue(viewTreeContainsText(trailingColumn, "上下文紧张 34%")); + assertEquals("空闲会话不应再渲染活动点", 2, trailingColumn.getChildCount()); + } + + private static boolean viewTreeContainsText(View root, String expectedText) { + if (root instanceof TextView) { + CharSequence text = ((TextView) root).getText(); + if (expectedText.contentEquals(text)) { + return true; + } + } + if (!(root instanceof LinearLayout)) { + return false; + } + LinearLayout group = (LinearLayout) root; + for (int index = 0; index < group.getChildCount(); index += 1) { + if (viewTreeContainsText(group.getChildAt(index), expectedText)) { + return true; + } + } + return false; + } } diff --git a/android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperConversationStatusTest.java b/android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperConversationStatusTest.java new file mode 100644 index 0000000..6095406 --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperConversationStatusTest.java @@ -0,0 +1,118 @@ +package com.hyzq.boss; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Test; + +public class WechatSurfaceMapperConversationStatusTest { + @Test + public void toConversationRow_mapsContextStatusAndIdleActivity() { + JSONObject item = new StubJSONObject() + .withString("threadTitle", "北区试产线回归") + .withString("folderLabel", "归档确认") + .withString("lastMessagePreview", "线程链路已稳定") + .withString("latestReplyLabel", "09:26") + .withInt("activityIconCount", 0) + .withBoolean("mustFinishBeforeCompaction", false) + .withObject("contextBudgetIndicator", new StubJSONObject() + .withBoolean("visible", true) + .withInt("percent", 34) + .withString("level", "urgent")); + + WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item); + + assertEquals(0, row.activityIconCount); + assertEquals("上下文紧张 34%", row.contextStatusLabel); + assertEquals("urgent", row.contextStatusLevel); + } + + @Test + public void toConversationRow_prefersMustFinishContextStatus() { + JSONObject item = new StubJSONObject() + .withString("threadTitle", "北区试产线回归") + .withInt("activityIconCount", 0) + .withBoolean("mustFinishBeforeCompaction", true) + .withObject("contextBudgetIndicator", new StubJSONObject() + .withBoolean("visible", true) + .withInt("percent", 18) + .withString("level", "critical")); + + WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item); + + assertEquals("必须收尾", row.contextStatusLabel); + assertEquals("critical", row.contextStatusLevel); + } + + @Test + public void toConversationRow_hidesContextStatusWhenNotVisible() { + JSONObject item = new StubJSONObject() + .withString("threadTitle", "北区试产线回归") + .withInt("activityIconCount", 0) + .withObject("contextBudgetIndicator", new StubJSONObject() + .withBoolean("visible", false) + .withInt("percent", 71) + .withString("level", "safe")); + + WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item); + + assertNull(row.contextStatusLabel); + assertNull(row.contextStatusLevel); + } + + private static final class StubJSONObject extends JSONObject { + private final java.util.Map values = new java.util.HashMap<>(); + + StubJSONObject withString(String key, String value) { + values.put(key, value); + return this; + } + + StubJSONObject withInt(String key, int value) { + values.put(key, value); + return this; + } + + StubJSONObject withBoolean(String key, boolean value) { + values.put(key, value); + return this; + } + + StubJSONObject withObject(String key, JSONObject value) { + values.put(key, value); + return this; + } + + @Override + public String optString(String key, String defaultValue) { + Object value = values.get(key); + return value instanceof String ? (String) value : defaultValue; + } + + @Override + public int optInt(String key, int defaultValue) { + Object value = values.get(key); + return value instanceof Integer ? (Integer) value : defaultValue; + } + + @Override + public boolean optBoolean(String key, boolean defaultValue) { + Object value = values.get(key); + return value instanceof Boolean ? (Boolean) value : defaultValue; + } + + @Override + public JSONObject optJSONObject(String key) { + Object value = values.get(key); + return value instanceof JSONObject ? (JSONObject) value : null; + } + + @Override + public JSONArray optJSONArray(String key) { + Object value = values.get(key); + return value instanceof JSONArray ? (JSONArray) value : null; + } + } +} diff --git a/src/lib/boss-projections.ts b/src/lib/boss-projections.ts index 501ab69..07082ea 100644 --- a/src/lib/boss-projections.ts +++ b/src/lib/boss-projections.ts @@ -353,7 +353,7 @@ function buildConversationItem(state: BossState, project: Project): Conversation const topThread = threadViews[0]?.snapshot; const threadTitle = project.threadMeta?.threadDisplayName ?? project.name; const folderLabel = project.threadMeta?.folderName ?? ""; - const activityIconCount = project.threadMeta?.activityIconCount ?? 1; + const activityIconCount = deriveConversationActivityIconCount(state, project); const topPinnedLabel = isTopPinnedConversation(project) ? "置顶" : undefined; const groupMembers = project.isGroup ? project.groupMembers.map((member) => ({ @@ -392,17 +392,44 @@ function buildConversationItem(state: BossState, project: Project): Conversation }, groupMembers, contextBudgetIndicator: { - visible: !project.isGroup && Boolean(topThread), + visible: Boolean(topThread), style: "ring_percent", - percent: !project.isGroup ? topThread?.contextBudgetRemainingPct : undefined, - level: !project.isGroup ? topThread?.contextBudgetLevel : undefined, + percent: topThread?.contextBudgetRemainingPct, + level: topThread?.contextBudgetLevel, }, - contextBudgetSourceNodeId: !project.isGroup ? topThread?.nodeId : undefined, - contextBudgetUpdatedAt: !project.isGroup ? topThread?.capturedAt : undefined, + contextBudgetSourceNodeId: topThread?.nodeId, + contextBudgetUpdatedAt: topThread?.capturedAt, mustFinishBeforeCompaction: Boolean(topThread?.mustFinishBeforeCompaction), } satisfies ConversationItem; } +function deriveConversationActivityIconCount(state: BossState, project: Project): number { + let count = 0; + + if ( + state.dispatchPlans.some( + (plan) => plan.groupProjectId === project.id && plan.status === "pending_user_confirmation", + ) + ) { + count += 1; + } + + count += state.dispatchExecutions.filter( + (execution) => + (execution.groupProjectId === project.id || execution.targetProjectId === project.id) && + (execution.status === "queued" || execution.status === "running"), + ).length; + + count += state.masterAgentTasks.filter( + (task) => + task.projectId === project.id && + task.taskType !== "device_import_resolution" && + (task.status === "queued" || task.status === "running"), + ).length; + + return Math.max(0, Math.min(4, count)); +} + function sortConversationItems(items: ConversationItem[]) { return items.sort((a, b) => { if (a.projectId === "master-agent") return -1; @@ -461,6 +488,16 @@ export function getConversationHomeItems(state: BossState): ConversationItem[] { const device = project?.deviceIds[0] ? state.devices.find((entry) => entry.id === project.deviceIds[0]) : undefined; + const topContextItem = [...items] + .filter((item) => item.contextBudgetIndicator.visible) + .sort((a, b) => { + const aLevel = a.contextBudgetIndicator.level ?? "safe"; + const bLevel = b.contextBudgetIndicator.level ?? "safe"; + if (levelPriority[aLevel] !== levelPriority[bLevel]) { + return levelPriority[aLevel] - levelPriority[bLevel]; + } + return (a.contextBudgetIndicator.percent ?? 100) - (b.contextBudgetIndicator.percent ?? 100); + })[0]; passthrough.push({ conversationId: `folder-${folderKey}`, conversationType: "folder_archive", @@ -480,13 +517,7 @@ export function getConversationHomeItems(state: BossState): ConversationItem[] { latestItem.lastMessagePreview || latestItem.preview || `包含 ${items.length} 个线程,最近活跃:《${latestItem.threadTitle}》`, - activityIconCount: Math.max( - 1, - Math.min( - 4, - items.reduce((sum, entry) => sum + Math.max(1, entry.activityIconCount), 0), - ), - ), + activityIconCount: Math.max(0, Math.min(4, items.reduce((sum, entry) => sum + entry.activityIconCount, 0))), manualPinned: false, topPinnedLabel: undefined, latestReplyAt: latestItem.latestReplyAt, @@ -503,10 +534,12 @@ export function getConversationHomeItems(state: BossState): ConversationItem[] { primary: device?.avatar ?? latestItem.avatar.primary, }, contextBudgetIndicator: { - visible: false, + visible: Boolean(topContextItem?.contextBudgetIndicator.visible), style: "ring_percent", + percent: topContextItem?.contextBudgetIndicator.percent, + level: topContextItem?.contextBudgetIndicator.level, }, - mustFinishBeforeCompaction: false, + mustFinishBeforeCompaction: items.some((item) => item.mustFinishBeforeCompaction), }); } diff --git a/tests/conversation-home-items.test.ts b/tests/conversation-home-items.test.ts index 5ae9d14..d783f3c 100644 --- a/tests/conversation-home-items.test.ts +++ b/tests/conversation-home-items.test.ts @@ -119,3 +119,53 @@ test("conversation home groups multiple imported threads by folder while keeping ["发布回滚", "归档确认"], ); }); + +test("conversation items expose context status while keeping idle activity silent", async () => { + await setup(); + const state = await readState(); + + state.projects = state.projects.filter((project) => project.id === "master-agent"); + state.masterAgentTasks = []; + state.dispatchExecutions = []; + state.projects.push( + buildImportedThreadProject( + "mac-studio", + "boss-thread-1", + "Boss", + "boss", + "归档确认", + "thread-1", + "2026-03-30T11:00:00+08:00", + ), + ); + state.threadContextSnapshots = [ + { + snapshotId: "snapshot-1", + workerId: "mac-studio", + projectId: "boss-thread-1", + threadId: "thread-1", + title: "归档确认", + summary: "上下文预算进入紧张区,需要尽快收尾。", + contextBudgetRemainingPct: 34, + contextBudgetLevel: "urgent", + mustFinishBeforeCompaction: false, + estimatedRemainingTurns: 8, + estimatedRemainingLargeMessages: 3, + compactionCount: 0, + patchPending: false, + testsPending: false, + evidencePending: false, + checklist: [], + capturedAt: "2026-03-30T11:00:00+08:00", + }, + ]; + + const [masterAgent, threadConversation] = getConversationHomeItems(state); + + assert.equal(threadConversation.projectId, "boss-thread-1"); + assert.equal(threadConversation.contextBudgetIndicator.visible, true); + assert.equal(threadConversation.contextBudgetIndicator.percent, 34); + assert.equal(threadConversation.contextBudgetIndicator.level, "urgent"); + assert.equal(threadConversation.activityIconCount, 0); + assert.equal(masterAgent.activityIconCount, 0); +});