From 5157a0ac078cd8964bb627ce9e0435b6cea88622 Mon Sep 17 00:00:00 2001 From: kris Date: Sat, 4 Apr 2026 00:32:57 +0800 Subject: [PATCH] feat: add conversation selection mode and swipe tabs --- .../src/main/java/com/hyzq/boss/BossUi.java | 32 +++ .../main/java/com/hyzq/boss/MainActivity.java | 211 +++++++++++++++++- .../com/hyzq/boss/WechatSurfaceMapper.java | 19 +- ...MainActivityConversationSelectionTest.java | 116 ++++++++++ .../boss/MainActivitySwipeNavigationTest.java | 32 +++ ...atSurfaceMapperConversationStatusTest.java | 21 +- .../WechatSurfaceMapperTopActionTest.java | 20 +- src/lib/boss-projections.ts | 12 +- tests/conversation-home-items.test.ts | 30 +++ 9 files changed, 468 insertions(+), 25 deletions(-) create mode 100644 android/app/src/test/java/com/hyzq/boss/MainActivityConversationSelectionTest.java create mode 100644 android/app/src/test/java/com/hyzq/boss/MainActivitySwipeNavigationTest.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 77801bf..98ff45d 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossUi.java +++ b/android/app/src/main/java/com/hyzq/boss/BossUi.java @@ -565,6 +565,16 @@ public final class BossUi { Context context, WechatSurfaceMapper.ConversationRow row, @Nullable View.OnClickListener listener + ) { + return buildConversationRow(context, row, false, false, listener); + } + + public static LinearLayout buildConversationRow( + Context context, + WechatSurfaceMapper.ConversationRow row, + boolean selectionMode, + boolean selected, + @Nullable View.OnClickListener listener ) { LinearLayout card = new LinearLayout(context); card.setOrientation(LinearLayout.HORIZONTAL); @@ -583,6 +593,9 @@ public final class BossUi { card.setFocusable(true); card.setOnClickListener(listener); } + if (selectionMode) { + card.setContentDescription(selected ? "已选中会话" : "未选中会话"); + } card.addView(buildConversationAvatar(context, row)); @@ -662,6 +675,14 @@ public final class BossUi { )); trailingColumn.addView(timeView); + if (selectionMode) { + View selector = buildConversationSelectionIndicator(context, selected); + LinearLayout.LayoutParams selectorParams = new LinearLayout.LayoutParams(dp(context, 20), dp(context, 20)); + selectorParams.topMargin = dp(context, 8); + selector.setLayoutParams(selectorParams); + trailingColumn.addView(selector); + } + if (row.unreadCount > 0) { TextView unreadView = new TextView(context); unreadView.setText(row.unreadCount > 99 ? "99+" : String.valueOf(row.unreadCount)); @@ -730,6 +751,17 @@ public final class BossUi { return card; } + private static View buildConversationSelectionIndicator(Context context, boolean selected) { + View indicator = new View(context); + GradientDrawable drawable = new GradientDrawable(); + drawable.setShape(GradientDrawable.OVAL); + drawable.setColor(selected ? context.getColor(R.color.boss_green) : Color.WHITE); + drawable.setStroke(dp(context, 1.5f), selected ? context.getColor(R.color.boss_green) : Color.parseColor("#FFC8C8C8")); + indicator.setBackground(drawable); + indicator.setContentDescription(selected ? "已选中会话" : "未选中会话"); + return indicator; + } + public static LinearLayout buildConversationSectionHeader( Context context, String title, 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 ea548fb..f5f59dd 100644 --- a/android/app/src/main/java/com/hyzq/boss/MainActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/MainActivity.java @@ -6,6 +6,8 @@ import android.os.Handler; import android.os.Looper; import android.text.Editable; import android.text.TextWatcher; +import android.view.GestureDetector; +import android.view.MotionEvent; import android.view.View; import android.widget.Button; import android.widget.EditText; @@ -22,6 +24,10 @@ import org.json.JSONArray; import org.json.JSONObject; import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -69,8 +75,11 @@ public class MainActivity extends AppCompatActivity { private @Nullable String boundDeviceName; private String conversationSearchQuery = ""; private boolean pinnedConversationsCollapsed = false; + private boolean conversationSelectionMode = false; private boolean conversationAutoRefreshArmed = false; private boolean conversationAutoRefreshEnabled = false; + private final Set selectedConversationProjectIds = new LinkedHashSet<>(); + private @Nullable GestureDetector conversationPagerGestureDetector; private final Runnable conversationAutoRefreshRunnable = new Runnable() { @Override public void run() { @@ -186,6 +195,38 @@ public class MainActivity extends AppCompatActivity { tabDevices.setOnClickListener(v -> setActiveTab("devices", true)); tabMe.setOnClickListener(v -> setActiveTab("me", true)); screenRefresh.setOnRefreshListener(this::refreshCurrentTab); + conversationPagerGestureDetector = new GestureDetector(this, new GestureDetector.SimpleOnGestureListener() { + private static final int SWIPE_DISTANCE_THRESHOLD = 120; + private static final int SWIPE_VELOCITY_THRESHOLD = 120; + + @Override + public boolean onDown(MotionEvent e) { + return true; + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + if (e1 == null || e2 == null) { + return false; + } + float deltaX = e2.getX() - e1.getX(); + float deltaY = e2.getY() - e1.getY(); + if (Math.abs(deltaX) < Math.abs(deltaY)) { + return false; + } + if (Math.abs(deltaX) < SWIPE_DISTANCE_THRESHOLD || Math.abs(velocityX) < SWIPE_VELOCITY_THRESHOLD) { + return false; + } + handleHorizontalPageSwipe(deltaX < 0 ? 1 : -1); + return true; + } + }); + screenRefresh.setOnTouchListener((v, event) -> { + if (conversationPagerGestureDetector != null) { + conversationPagerGestureDetector.onTouchEvent(event); + } + return false; + }); } private void applyInitialTab(@Nullable Intent intent) { @@ -400,6 +441,9 @@ public class MainActivity extends AppCompatActivity { } private void setActiveTab(String tab, boolean fromUser) { + if (!"conversations".equals(tab)) { + exitConversationSelectionMode(); + } activeTab = tab; if (fromUser) { userSelectedTab = true; @@ -437,7 +481,7 @@ public class MainActivity extends AppCompatActivity { case "conversations": default: updateHeader("会话", WechatSurfaceMapper.conversationsHeaderSubtitle()); - configureTopAction(WechatSurfaceMapper.rootTopAction(activeTab, false)); + configureTopAction(WechatSurfaceMapper.rootTopAction(activeTab, false, conversationSelectionMode)); renderConversationsRoot(); break; } @@ -474,19 +518,23 @@ public class MainActivity extends AppCompatActivity { } private void syncTopActionVisualState(boolean refreshing) { - WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, refreshing); + WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, refreshing, conversationSelectionMode); configureTopAction(action); refreshButton.setEnabled(!"refresh".equals(action.actionKey) || !refreshing); } private void handleTopAction() { - String actionKey = WechatSurfaceMapper.rootTopAction(activeTab, false).actionKey; + String actionKey = WechatSurfaceMapper.rootTopAction(activeTab, false, conversationSelectionMode).actionKey; if ("add_device".equals(actionKey)) { startActivity(new Intent(this, DeviceEnrollmentActivity.class)); return; } - if ("create_group_chat".equals(actionKey)) { - startActivity(new Intent(this, GroupCreateActivity.class)); + if ("select_conversations".equals(actionKey)) { + enterConversationSelectionMode(); + return; + } + if ("cancel_select_conversations".equals(actionKey)) { + exitConversationSelectionMode(); return; } refreshCurrentTab(); @@ -495,6 +543,9 @@ public class MainActivity extends AppCompatActivity { private void renderConversationsRoot() { screenContent.removeAllViews(); screenContent.addView(buildConversationSearchInput()); + if (conversationSelectionMode) { + appendConversationSelectionControls(); + } JSONArray filteredConversations = filterConversationItems(conversationsData, conversationSearchQuery); if (filteredConversations == null || filteredConversations.length() == 0) { screenContent.addView(BossUi.buildEmptyCard(this, "当前没有会话数据。")); @@ -541,7 +592,21 @@ public class MainActivity extends AppCompatActivity { screenContent.addView(BossUi.buildConversationRow( this, row, + conversationSelectionMode, + selectedConversationProjectIds.contains(projectId), v -> { + if (conversationSelectionMode) { + if (!"single_device".equals(conversationType)) { + showMessage("只能选择线程会话发起群聊"); + return; + } + if (projectId.isEmpty()) { + showMessage("缺少 projectId"); + return; + } + toggleConversationSelection(projectId); + return; + } if ("folder_archive".equals(conversationType)) { if (folderKey.isEmpty()) { showMessage("缺少 folderKey"); @@ -560,6 +625,142 @@ public class MainActivity extends AppCompatActivity { } } + private void appendConversationSelectionControls() { + screenContent.addView(buildSelectionSummaryView()); + + Button cancelButton = BossUi.buildMiniActionButton(this, "取消", false); + cancelButton.setOnClickListener(v -> exitConversationSelectionMode()); + + Button createButton = BossUi.buildMiniActionButton(this, "发起群聊", true); + createButton.setEnabled(selectedConversationProjectIds.size() >= 2); + createButton.setOnClickListener(v -> createStandaloneGroupChatFromSelection()); + + screenContent.addView(BossUi.buildInlineActionRow(this, cancelButton, createButton)); + } + + private LinearLayout buildSelectionSummaryView() { + LinearLayout summaryWrap = new LinearLayout(this); + summaryWrap.setOrientation(LinearLayout.VERTICAL); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + params.leftMargin = BossUi.dp(this, 16); + params.rightMargin = BossUi.dp(this, 16); + params.bottomMargin = BossUi.dp(this, 10); + summaryWrap.setLayoutParams(params); + + int count = selectedConversationProjectIds.size(); + if (count > 0) { + TextView selectedView = new TextView(this); + selectedView.setText("已选 " + count + " 个线程"); + selectedView.setTextSize(13); + selectedView.setTextColor(getColor(R.color.boss_text_primary)); + summaryWrap.addView(selectedView); + } + if (count < 2) { + TextView hintView = new TextView(this); + hintView.setText("至少选择 2 个线程"); + hintView.setTextSize(12); + hintView.setTextColor(getColor(R.color.boss_text_muted)); + if (count > 0) { + hintView.setPadding(0, BossUi.dp(this, 4), 0, 0); + } + summaryWrap.addView(hintView); + } + return summaryWrap; + } + + private void enterConversationSelectionMode() { + conversationSelectionMode = true; + selectedConversationProjectIds.clear(); + syncTopActionVisualState(screenRefresh.isRefreshing()); + renderConversationsRoot(); + } + + private void exitConversationSelectionMode() { + if (!conversationSelectionMode && selectedConversationProjectIds.isEmpty()) { + return; + } + conversationSelectionMode = false; + selectedConversationProjectIds.clear(); + syncTopActionVisualState(screenRefresh.isRefreshing()); + if ("conversations".equals(activeTab) && contentPanel.getVisibility() == View.VISIBLE) { + renderConversationsRoot(); + } + } + + private void toggleConversationSelection(String projectId) { + if (selectedConversationProjectIds.contains(projectId)) { + selectedConversationProjectIds.remove(projectId); + } else { + selectedConversationProjectIds.add(projectId); + } + renderConversationsRoot(); + } + + private void createStandaloneGroupChatFromSelection() { + if (selectedConversationProjectIds.size() < 2) { + showMessage("至少选择 2 个线程"); + return; + } + List snapshot = new ArrayList<>(selectedConversationProjectIds); + startRefreshing(true); + executor.execute(() -> { + try { + JSONObject payload = new JSONObject(); + JSONArray memberProjectIds = new JSONArray(); + for (String projectId : snapshot) { + memberProjectIds.put(projectId); + } + payload.put("memberProjectIds", memberProjectIds); + BossApiClient.ApiResponse response = apiClient.createStandaloneGroupChat(payload); + if (!response.ok()) { + throw new IllegalStateException(response.message()); + } + JSONObject project = response.json.optJSONObject("project"); + if (project == null) { + throw new IllegalStateException("GROUP_CHAT_PROJECT_MISSING"); + } + String createdProjectId = project.optString("id", ""); + if (createdProjectId.isEmpty()) { + throw new IllegalStateException("GROUP_CHAT_PROJECT_ID_MISSING"); + } + String createdProjectName = project.optString("name", "群聊"); + runOnUiThread(() -> { + startRefreshing(false); + exitConversationSelectionMode(); + showMessage("群聊已创建"); + openProject(createdProjectId, createdProjectName); + refreshCurrentTab(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + startRefreshing(false); + showMessage("创建失败:" + error.getMessage()); + syncTopActionVisualState(false); + renderConversationsRoot(); + }); + } + }); + } + + private void handleHorizontalPageSwipe(int direction) { + String[] order = new String[] {"conversations", "devices", "me"}; + int currentIndex = 0; + for (int i = 0; i < order.length; i++) { + if (order[i].equals(activeTab)) { + currentIndex = i; + break; + } + } + int nextIndex = currentIndex + direction; + if (nextIndex < 0 || nextIndex >= order.length) { + return; + } + setActiveTab(order[nextIndex], true); + } + private EditText buildConversationSearchInput() { EditText input = BossUi.buildInput(this, "搜索项目或线程", false); LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( 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 59821e0..c7000c9 100644 --- a/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java +++ b/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java @@ -176,7 +176,7 @@ public final class WechatSurfaceMapper { } JSONObject indicator = source.optJSONObject("contextBudgetIndicator"); if (indicator == null || !indicator.optBoolean("visible", false)) { - return null; + return "上下文稳定"; } String level = indicator.optString("level", "safe"); int percent = indicator.optInt("percent", -1); @@ -199,7 +199,7 @@ public final class WechatSurfaceMapper { } JSONObject indicator = source.optJSONObject("contextBudgetIndicator"); if (indicator == null || !indicator.optBoolean("visible", false)) { - return null; + return "safe"; } return indicator.optString("level", null); } @@ -210,25 +210,32 @@ public final class WechatSurfaceMapper { } JSONObject indicator = source.optJSONObject("contextBudgetIndicator"); if (indicator == null || !indicator.optBoolean("visible", false)) { - return -1; + return 0; } int remainingPercent = indicator.optInt("percent", -1); if (remainingPercent < 0) { - return -1; + return 0; } return Math.max(0, Math.min(100, 100 - remainingPercent)); } private static boolean hasContextIndicator(JSONObject source) { - return resolveContextUsagePercent(source) >= 0; + return true; } public static RootTopAction rootTopAction(String activeTab, boolean refreshing) { + return rootTopAction(activeTab, refreshing, false); + } + + public static RootTopAction rootTopAction(String activeTab, boolean refreshing, boolean selectionMode) { if ("devices".equals(activeTab)) { return new RootTopAction("+添加", true, false, "add_device"); } if ("conversations".equals(activeTab)) { - return new RootTopAction("+", false, true, "create_group_chat"); + if (selectionMode) { + return new RootTopAction("取消", false, false, "cancel_select_conversations"); + } + return new RootTopAction("选择", false, false, "select_conversations"); } return new RootTopAction(refreshing ? "同步中" : "刷新", false, false, "refresh"); } diff --git a/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSelectionTest.java b/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSelectionTest.java new file mode 100644 index 0000000..09a1dcf --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSelectionTest.java @@ -0,0 +1,116 @@ +package com.hyzq.boss; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.view.View; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.util.ReflectionHelpers; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34) +public class MainActivityConversationSelectionTest { + @Test + public void conversationsSelectionMode_requiresAtLeastTwoSelectionsForGroupChat() throws Exception { + MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get(); + ReflectionHelpers.setField(activity, "conversationsData", buildConversations()); + ReflectionHelpers.callInstanceMethod(activity, "showContent"); + + ReflectionHelpers.callInstanceMethod(activity, "enterConversationSelectionMode"); + assertTrue(ReflectionHelpers.getField(activity, "conversationSelectionMode")); + + LinearLayout content = activity.findViewById(R.id.screen_content); + assertTrue(viewTreeContainsText(content, "发起群聊")); + assertTrue(viewTreeContainsText(content, "至少选择 2 个线程")); + + ReflectionHelpers.callInstanceMethod(activity, "toggleConversationSelection", + ReflectionHelpers.ClassParameter.from(String.class, "thread-1")); + content = activity.findViewById(R.id.screen_content); + assertTrue(viewTreeContainsText(content, "已选 1 个线程")); + assertTrue(viewTreeContainsText(content, "至少选择 2 个线程")); + + ReflectionHelpers.callInstanceMethod(activity, "toggleConversationSelection", + ReflectionHelpers.ClassParameter.from(String.class, "thread-2")); + content = activity.findViewById(R.id.screen_content); + assertTrue(viewTreeContainsText(content, "已选 2 个线程")); + assertFalse(viewTreeContainsText(content, "至少选择 2 个线程")); + } + + @Test + public void selectionModeRowsRenderSelectorInTrailingArea() throws Exception { + MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get(); + ReflectionHelpers.setField(activity, "conversationsData", buildConversations()); + ReflectionHelpers.callInstanceMethod(activity, "showContent"); + ReflectionHelpers.callInstanceMethod(activity, "enterConversationSelectionMode"); + + LinearLayout content = activity.findViewById(R.id.screen_content); + View row = content.getChildAt(3); + assertTrue("多选模式应显示单选圆点", viewTreeContainsContentDescription(row, "未选中会话")); + } + + private static JSONArray buildConversations() throws Exception { + return new JSONArray() + .put(new JSONObject() + .put("projectId", "thread-1") + .put("projectTitle", "Boss 移动控制台") + .put("threadTitle", "Boss 移动控制台") + .put("folderLabel", "Boss") + .put("lastMessagePreview", "线程链路正常") + .put("latestReplyLabel", "09:41") + .put("conversationType", "single_device")) + .put(new JSONObject() + .put("projectId", "thread-2") + .put("projectTitle", "硬件审计协作") + .put("threadTitle", "硬件审计协作") + .put("folderLabel", "Mac Studio") + .put("lastMessagePreview", "检查摄像头供电链路") + .put("latestReplyLabel", "09:42") + .put("conversationType", "single_device")); + } + + 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; + } + + private static boolean viewTreeContainsContentDescription(View root, String expectedText) { + CharSequence description = root.getContentDescription(); + if (expectedText.contentEquals(description)) { + return true; + } + if (!(root instanceof LinearLayout)) { + return false; + } + LinearLayout group = (LinearLayout) root; + for (int index = 0; index < group.getChildCount(); index += 1) { + if (viewTreeContainsContentDescription(group.getChildAt(index), expectedText)) { + return true; + } + } + return false; + } +} diff --git a/android/app/src/test/java/com/hyzq/boss/MainActivitySwipeNavigationTest.java b/android/app/src/test/java/com/hyzq/boss/MainActivitySwipeNavigationTest.java new file mode 100644 index 0000000..86cb71f --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/MainActivitySwipeNavigationTest.java @@ -0,0 +1,32 @@ +package com.hyzq.boss; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.util.ReflectionHelpers; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34) +public class MainActivitySwipeNavigationTest { + @Test + public void horizontalSwipeMovesBetweenRootTabs() { + MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get(); + ReflectionHelpers.callInstanceMethod(activity, "showContent"); + + ReflectionHelpers.callInstanceMethod(activity, "handleHorizontalPageSwipe", + ReflectionHelpers.ClassParameter.from(int.class, 1)); + assertEquals("devices", ReflectionHelpers.getField(activity, "activeTab")); + + ReflectionHelpers.callInstanceMethod(activity, "handleHorizontalPageSwipe", + ReflectionHelpers.ClassParameter.from(int.class, 1)); + assertEquals("me", ReflectionHelpers.getField(activity, "activeTab")); + + ReflectionHelpers.callInstanceMethod(activity, "handleHorizontalPageSwipe", + ReflectionHelpers.ClassParameter.from(int.class, -1)); + assertEquals("devices", ReflectionHelpers.getField(activity, "activeTab")); + } +} diff --git a/android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperConversationStatusTest.java b/android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperConversationStatusTest.java index f5f0304..9f3df47 100644 --- a/android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperConversationStatusTest.java +++ b/android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperConversationStatusTest.java @@ -62,9 +62,24 @@ public class WechatSurfaceMapperConversationStatusTest { WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item); - assertNull(row.contextStatusLabel); - assertNull(row.contextStatusLevel); - assertEquals(-1, row.contextUsagePercent); + assertEquals("上下文稳定", row.contextStatusLabel); + assertEquals("safe", row.contextStatusLevel); + assertEquals(0, row.contextUsagePercent); + assertEquals(true, row.contextIndicatorVisible); + } + + @Test + public void toConversationRow_usesSafeContextDefaultsWhenIndicatorMissing() { + JSONObject item = new StubJSONObject() + .withString("threadTitle", "北区试产线回归") + .withInt("activityIconCount", 0); + + WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item); + + assertEquals("上下文稳定", row.contextStatusLabel); + assertEquals("safe", row.contextStatusLevel); + assertEquals(0, row.contextUsagePercent); + assertEquals(true, row.contextIndicatorVisible); } private static final class StubJSONObject extends JSONObject { diff --git a/android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTopActionTest.java b/android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTopActionTest.java index f835881..862d975 100644 --- a/android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTopActionTest.java +++ b/android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTopActionTest.java @@ -8,13 +8,23 @@ import org.junit.Test; public class WechatSurfaceMapperTopActionTest { @Test - public void rootTopAction_usesPlusForConversations() { - WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction("conversations", false); + public void rootTopAction_usesSelectionForConversations() { + WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction("conversations", false, false); - assertEquals("+", action.label); + assertEquals("选择", action.label); assertFalse(action.primaryStyle); - assertTrue(action.compactStyle); - assertEquals("create_group_chat", action.actionKey); + assertFalse(action.compactStyle); + assertEquals("select_conversations", action.actionKey); + } + + @Test + public void rootTopAction_usesCancelDuringConversationSelection() { + WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction("conversations", false, true); + + assertEquals("取消", action.label); + assertFalse(action.primaryStyle); + assertFalse(action.compactStyle); + assertEquals("cancel_select_conversations", action.actionKey); } @Test diff --git a/src/lib/boss-projections.ts b/src/lib/boss-projections.ts index 07082ea..c90b1d4 100644 --- a/src/lib/boss-projections.ts +++ b/src/lib/boss-projections.ts @@ -392,10 +392,10 @@ function buildConversationItem(state: BossState, project: Project): Conversation }, groupMembers, contextBudgetIndicator: { - visible: Boolean(topThread), + visible: true, style: "ring_percent", - percent: topThread?.contextBudgetRemainingPct, - level: topThread?.contextBudgetLevel, + percent: topThread?.contextBudgetRemainingPct ?? 100, + level: topThread?.contextBudgetLevel ?? "safe", }, contextBudgetSourceNodeId: topThread?.nodeId, contextBudgetUpdatedAt: topThread?.capturedAt, @@ -534,10 +534,10 @@ export function getConversationHomeItems(state: BossState): ConversationItem[] { primary: device?.avatar ?? latestItem.avatar.primary, }, contextBudgetIndicator: { - visible: Boolean(topContextItem?.contextBudgetIndicator.visible), + visible: true, style: "ring_percent", - percent: topContextItem?.contextBudgetIndicator.percent, - level: topContextItem?.contextBudgetIndicator.level, + percent: topContextItem?.contextBudgetIndicator.percent ?? 100, + level: topContextItem?.contextBudgetIndicator.level ?? "safe", }, mustFinishBeforeCompaction: items.some((item) => item.mustFinishBeforeCompaction), }); diff --git a/tests/conversation-home-items.test.ts b/tests/conversation-home-items.test.ts index d783f3c..82db352 100644 --- a/tests/conversation-home-items.test.ts +++ b/tests/conversation-home-items.test.ts @@ -168,4 +168,34 @@ test("conversation items expose context status while keeping idle activity silen assert.equal(threadConversation.contextBudgetIndicator.level, "urgent"); assert.equal(threadConversation.activityIconCount, 0); assert.equal(masterAgent.activityIconCount, 0); + assert.equal(masterAgent.contextBudgetIndicator.visible, true); + assert.equal(masterAgent.contextBudgetIndicator.percent, 100); + assert.equal(masterAgent.contextBudgetIndicator.level, "safe"); +}); + +test("conversation items keep a safe context ring even when no thread snapshot exists", async () => { + await setup(); + const state = await readState(); + + state.projects = state.projects.filter((project) => project.id === "master-agent"); + state.projects.push( + buildImportedThreadProject( + "mac-studio", + "single-thread-no-context", + "Talking", + "talking", + "调试回归", + "thread-no-context", + "2026-03-30T11:20:00+08:00", + ), + ); + state.threadContextSnapshots = []; + + const items = getConversationHomeItems(state); + const directThread = items.find((item) => item.projectId === "single-thread-no-context"); + + assert.ok(directThread); + assert.equal(directThread?.contextBudgetIndicator.visible, true); + assert.equal(directThread?.contextBudgetIndicator.percent, 100); + assert.equal(directThread?.contextBudgetIndicator.level, "safe"); });