fix: show conversation context status
This commit is contained in:
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, Object> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user