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 e1d3196..6683955 100644 --- a/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java +++ b/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java @@ -59,7 +59,9 @@ public final class WechatSurfaceMapper { JSONObject avatar = source.optJSONObject("avatar"); boolean isGroup = source.optBoolean("isGroup", groupAvatarMembers.size() > 1); String conversationType = source.optString("conversationType", ""); - String threadTitle = source.optString("threadTitle", source.optString("title", source.optString("projectTitle", ""))); + String threadTitle = trimLocalWorkspacePrefix( + source.optString("threadTitle", source.optString("title", source.optString("projectTitle", ""))) + ); if ("folder_archive".equals(conversationType)) { threadTitle = source.optString( "projectTitle", @@ -70,7 +72,7 @@ public final class WechatSurfaceMapper { return new ConversationRow( threadTitle, source.optString("folderLabel", ""), - source.optString("lastMessagePreview", source.optString("preview", "")), + compactImportedThreadPreview(source.optString("lastMessagePreview", source.optString("preview", ""))), source.optString("timeLabel", source.optString("latestReplyLabel", "")), source.optInt("unreadCount", 0), pinnedLabel, @@ -408,7 +410,7 @@ public final class WechatSurfaceMapper { ); putIfNotEmpty(folder, "projectTitle", projectTitle); putIfNotEmpty(folder, "threadTitle", projectTitle); - String recentThread = latest.optString("threadTitle", "").trim(); + String recentThread = trimLocalWorkspacePrefix(latest.optString("threadTitle", "").trim()); putIfNotEmpty( folder, "folderLabel", @@ -422,11 +424,16 @@ public final class WechatSurfaceMapper { safePut(folder, "searchAliases", searchAliases); safePut(folder, "searchTargetProjectIds", searchTargetProjectIds); } - putIfNotEmpty(folder, "preview", firstNonBlank(latest.optString("preview", ""), latest.optString("lastMessagePreview", ""))); + String compactPreview = compactImportedThreadPreview( + firstNonBlank(latest.optString("preview", ""), latest.optString("lastMessagePreview", "")) + ); + putIfNotEmpty(folder, "preview", compactPreview); putIfNotEmpty( folder, "lastMessagePreview", - firstNonBlank(latest.optString("lastMessagePreview", ""), latest.optString("preview", "")) + compactImportedThreadPreview( + firstNonBlank(latest.optString("lastMessagePreview", ""), latest.optString("preview", "")) + ) ); safePut(folder, "activityIconCount", Math.max(0, Math.min(4, sumInt(items, "activityIconCount")))); if (hasPinnedLabel(items)) { @@ -637,6 +644,26 @@ public final class WechatSurfaceMapper { } } + private static String compactImportedThreadPreview(String value) { + String preview = value == null ? "" : value.trim(); + if (preview.matches("^已从设备.+导入线程《.+》[。.]?$")) { + return "已导入线程"; + } + return preview; + } + + private static String trimLocalWorkspacePrefix(String value) { + String label = value == null ? "" : value.trim(); + if (label.isEmpty()) { + return ""; + } + String normalized = label.replace('\\', '/'); + return normalized + .replaceFirst("^/Users/[^/]+/code/", "") + .replaceFirst("^/home/[^/]+/code/", "") + .replaceFirst("^[A-Za-z]:/Users/[^/]+/code/", ""); + } + private static String firstNonBlank(String... values) { if (values == null) { return ""; diff --git a/android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java b/android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java index dfed64f..01e451a 100644 --- a/android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java +++ b/android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java @@ -1,6 +1,9 @@ package com.hyzq.boss; import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; import org.json.JSONArray; import org.json.JSONObject; @@ -12,6 +15,8 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34) public class WechatSurfaceMapperTest { @Test public void toConversationRow_mapsWechatConversationFieldsFromThreadPayload() throws Exception { @@ -79,6 +84,59 @@ public class WechatSurfaceMapperTest { assertEquals("", row.avatarPrimary); } + @Test + public void normalizeConversationHomeFeed_compactsImportedFolderPreviewAndTrimsWorkspacePrefix() throws Exception { + JSONArray source = new JSONArray() + .put(new JSONObject() + .put("conversationType", "single_device") + .put("projectId", "wenshen-thread-1") + .put("folderKey", "mac-studio:/users/kris/code/wenshenapp") + .put("projectTitle", "wenshenapp") + .put("threadTitle", "/Users/kris/code/wenshenapp/docs/superpowers/specs/2026-04-06-context-cleanup-design.md") + .put("folderLabel", "wenshenapp") + .put("preview", "已从设备 Mac Studio 导入线程《wenshenapp · 019d60》。") + .put("lastMessagePreview", "已从设备 Mac Studio 导入线程《wenshenapp · 019d60》。") + .put("latestReplyAt", "2026-04-06T17:35:00+08:00") + .put("latestReplyLabel", "17:35")) + .put(new JSONObject() + .put("conversationType", "single_device") + .put("projectId", "wenshen-thread-2") + .put("folderKey", "mac-studio:/users/kris/code/wenshenapp") + .put("projectTitle", "wenshenapp") + .put("threadTitle", "补齐 Android 会话可读性") + .put("folderLabel", "wenshenapp") + .put("preview", "最近消息:补齐 Android 会话可读性") + .put("lastMessagePreview", "最近消息:补齐 Android 会话可读性") + .put("latestReplyAt", "2026-04-06T16:30:00+08:00") + .put("latestReplyLabel", "16:30")); + + JSONArray normalized = WechatSurfaceMapper.normalizeConversationHomeFeed(source); + JSONObject folder = normalized.optJSONObject(0); + + assertNotNull(folder); + assertEquals("folder_archive", folder.optString("conversationType", "")); + assertEquals("2 个线程 · 最近:wenshenapp/docs/superpowers/specs/2026-04-06-context-cleanup-design.md", folder.optString("folderLabel", "")); + assertEquals("已导入线程", folder.optString("preview", "")); + assertEquals("已导入线程", folder.optString("lastMessagePreview", "")); + } + + @Test + public void toConversationRow_compactsImportedPreviewAndTrimsWorkspacePrefixForSingleThread() throws Exception { + JSONObject item = new JSONObject() + .put("conversationType", "single_device") + .put("projectTitle", "zhanglaoshi") + .put("threadTitle", "/Users/kris/code/zhanglaoshi/docs/superpowers/specs/2026-04-06-single-thread-cleanup-design.md") + .put("folderLabel", "zhanglaoshi") + .put("preview", "已从设备 Mac Studio 导入线程《zhanglaoshi · 019d61》。") + .put("lastMessagePreview", "已从设备 Mac Studio 导入线程《zhanglaoshi · 019d61》。") + .put("latestReplyLabel", "17:35"); + + WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item); + + assertEquals("zhanglaoshi/docs/superpowers/specs/2026-04-06-single-thread-cleanup-design.md", row.threadTitle); + assertEquals("已导入线程", row.lastMessagePreview); + } + @Test public void toDeviceRow_mapsLegacyWechatThreeLineSummary() throws Exception { JSONObject item = new StubJSONObject() diff --git a/src/lib/boss-projections.ts b/src/lib/boss-projections.ts index 57f1477..31cf8cb 100644 --- a/src/lib/boss-projections.ts +++ b/src/lib/boss-projections.ts @@ -355,11 +355,12 @@ function buildConversationItem(state: BossState, project: Project): Conversation const devices = state.devices.filter((device) => project.deviceIds.includes(device.id)); const threadViews = threadViewsForProject(state, project.id); const topThread = threadViews[0]?.snapshot; - const threadTitle = project.threadMeta?.threadDisplayName ?? project.name; + const threadTitle = trimLocalWorkspacePrefix(project.threadMeta?.threadDisplayName ?? project.name); const folderLabel = project.threadMeta?.folderName ?? ""; const activityIconCount = deriveConversationActivityIconCount(state, project); const topPinnedLabel = isTopPinnedConversation(project) ? "置顶" : undefined; const latestConversationActivityAt = deriveLatestConversationActivityAt(project); + const compactPreview = compactImportedThreadPreview(project.preview); const groupMembers = project.isGroup ? project.groupMembers.map((member) => ({ threadId: member.threadId, @@ -379,8 +380,8 @@ function buildConversationItem(state: BossState, project: Project): Conversation threadTitle, folderLabel, folderKey: buildFolderKey(project), - preview: project.preview, - lastMessagePreview: project.preview, + preview: compactPreview, + lastMessagePreview: compactPreview, activityIconCount, topPinnedLabel, manualPinned: Boolean(project.pinned && !project.systemPinned), diff --git a/tests/conversation-home-items.test.ts b/tests/conversation-home-items.test.ts index b630981..bbba353 100644 --- a/tests/conversation-home-items.test.ts +++ b/tests/conversation-home-items.test.ts @@ -581,6 +581,34 @@ test("conversation home compacts imported previews and trims local workspace pre assert.equal(folder?.lastMessagePreview, "已导入线程"); }); +test("conversation home compacts imported previews and trims local workspace prefixes for single-thread items", async () => { + await setup(); + const state = await readState(); + + state.projects = state.projects.filter((project) => project.id === "master-agent"); + state.projects.push({ + ...buildImportedThreadProject( + "mac-studio", + "zhanglaoshi-thread-1", + "zhanglaoshi", + "/Users/kris/code/zhanglaoshi", + "/Users/kris/code/zhanglaoshi/docs/superpowers/specs/2026-04-06-single-thread-cleanup-design.md", + "thread-1", + "2026-04-06T15:00:00+08:00", + ), + preview: "已从设备 Mac Studio 导入线程《zhanglaoshi · 019d61》。", + lastMessageAt: "2026-04-06T15:00:00+08:00", + }); + + const item = getConversationHomeItems(state).find((entry) => entry.projectId === "zhanglaoshi-thread-1"); + + assert.ok(item, "expected single imported thread item"); + assert.equal(item?.conversationType, "single_device"); + assert.equal(item?.threadTitle, "zhanglaoshi/docs/superpowers/specs/2026-04-06-single-thread-cleanup-design.md"); + assert.equal(item?.preview, "已导入线程"); + assert.equal(item?.lastMessagePreview, "已导入线程"); +}); + test("folder archive homepage rows expose pin toggles when the folder is pinned", async () => { await setup(); const state = await readState();