From ef1794763586c1a7deb10d03918efffe10da8d42 Mon Sep 17 00:00:00 2001 From: kris Date: Sun, 5 Apr 2026 13:05:28 +0800 Subject: [PATCH] docs: add conversation folder drawer implementation plan --- .../2026-04-05-conversation-folder-drawer.md | 745 ++++++++++++++++++ 1 file changed, 745 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-05-conversation-folder-drawer.md diff --git a/docs/superpowers/plans/2026-04-05-conversation-folder-drawer.md b/docs/superpowers/plans/2026-04-05-conversation-folder-drawer.md new file mode 100644 index 0000000..cdb0e61 --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-conversation-folder-drawer.md @@ -0,0 +1,745 @@ +# 会话首页项目文件夹与抽屉模式 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让 Boss 首页稳定呈现“单线程项目直显、2 个及以上线程项目显示为项目文件夹”的微信式会话结构,并保证搜索、置顶、排序、文件夹页和前台渲染都一致。 + +**Architecture:** 基于现有 `folder_archive` 半实现继续收口,而不是推翻重做。服务端投影负责把会话项稳定聚合成“单线程/项目文件夹”两种模型,Web 与 Android 首页只消费聚合后的首页项;搜索继续保留线程可达性,但点击多线程项目中的线程时仍先回到项目文件夹语义。 + +**Tech Stack:** Next.js App Router、TypeScript、文件型状态存储 `data/boss-state.json`、Android 原生客户端、Node test runner、Gradle unit tests + +--- + +## 文件结构 + +### 服务端首页投影与文件夹详情 + +- Modify: `/Users/kris/code/boss/src/lib/boss-projections.ts` +- Test: `/Users/kris/code/boss/tests/conversation-home-items.test.ts` + +职责: +- 收紧 `getConversationHomeItems()` 聚合规则 +- 明确文件夹项副标题、时间、预览、上下文环和活动状态的来源 +- 保证 `1 ↔ 2+` 线程数变化时首页自动升降级 +- 保持 `getConversationFolderView()` 与首页聚合一致 + +### Web 会话首页与搜索 + +- Modify: `/Users/kris/code/boss/src/components/app-ui.tsx` +- Test: `/Users/kris/code/boss/tests/conversation-home-items.test.ts` + +职责: +- 首页渲染项目文件夹会话项 +- 搜索保留线程可达性,但不打破文件夹心智 +- 文件夹项副标题展示 `N 个线程 · 最近:某线程` + +### Android 会话首页与搜索 + +- Modify: `/Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/MainActivity.java` +- Modify: `/Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java` +- Test: `/Users/kris/code/boss/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSearchTest.java` +- Test: `/Users/kris/code/boss/android/app/src/test/java/com/hyzq/boss/MainActivityPinnedConversationsTest.java` +- Test: `/Users/kris/code/boss/android/app/src/test/java/com/hyzq/boss/MainActivityRootListAdapterTest.java` + +职责: +- 首页按聚合后的会话项渲染 +- 搜索命中多线程项目里的线程时,结果显示 `项目名 / 线程名` +- 点击后仍进入文件夹页,不直接打平首页结构 + +### Android 文件夹页与回跳 + +- Modify: `/Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java` +- Test: `/Users/kris/code/boss/android/app/src/test/java/com/hyzq/boss/ConversationFolderActivityTest.java` + +职责: +- 把现有线程列表页收成“项目内部线程页” +- 文件夹页标题、副标题、线程数和线程行展示与首页语义一致 +- 支持从搜索结果进入时定位到对应线程 + +### 文档与回归 + +- Modify: `/Users/kris/code/boss/README.md` +- Modify: `/Users/kris/code/boss/docs/architecture/current_runtime_and_deploy_status_cn.md` + +职责: +- 记录首页项目文件夹规则和搜索/导航行为 +- 记录“单线程直显、2+ 线程归档”的正式产品逻辑 + +--- + +### Task 1: 收紧服务端首页聚合规则 + +**Files:** +- Modify: `/Users/kris/code/boss/src/lib/boss-projections.ts` +- Test: `/Users/kris/code/boss/tests/conversation-home-items.test.ts` + +- [ ] **Step 1: 先写失败测试,锁住文件夹会话的副标题、时间、预览和上下文环来源** + +```ts +test("folder archive item uses latest thread preview and most urgent context thread", 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-1", + "Boss", + "boss", + "Android UI 收尾", + "thread-1", + "2026-04-05T10:00:00+08:00", + ), + buildImportedThreadProject( + "mac-studio", + "boss-thread-2", + "Boss", + "boss", + "发布回滚", + "thread-2", + "2026-04-05T11:00:00+08:00", + ), + ); + + state.projects[0]!.preview = "旧预览"; + state.projects[1]!.preview = "最近:发布回滚"; + state.threadContextSnapshots = [ + { + snapshotId: "snapshot-a", + workerId: "mac-studio", + projectId: "boss-thread-1", + threadId: "thread-1", + title: "Android UI 收尾", + summary: "", + contextBudgetRemainingPct: 72, + contextBudgetLevel: "safe", + mustFinishBeforeCompaction: false, + estimatedRemainingTurns: 20, + estimatedRemainingLargeMessages: 10, + compactionCount: 0, + patchPending: false, + testsPending: false, + evidencePending: false, + checklist: [], + capturedAt: "2026-04-05T10:05:00+08:00", + }, + { + snapshotId: "snapshot-b", + workerId: "mac-studio", + projectId: "boss-thread-2", + threadId: "thread-2", + title: "发布回滚", + summary: "", + contextBudgetRemainingPct: 18, + contextBudgetLevel: "critical", + mustFinishBeforeCompaction: true, + estimatedRemainingTurns: 2, + estimatedRemainingLargeMessages: 1, + compactionCount: 1, + patchPending: false, + testsPending: false, + evidencePending: false, + checklist: [], + capturedAt: "2026-04-05T11:02:00+08:00", + }, + ]; + + const items = getConversationHomeItems(state); + const folder = items.find((item) => item.conversationType === "folder_archive"); + + assert.ok(folder); + assert.equal(folder?.projectTitle, "发布回滚"); + assert.equal(folder?.threadTitle, "Boss"); + assert.equal(folder?.threadCount, 2); + assert.equal(folder?.preview, "最近:发布回滚"); + assert.match(folder?.folderLabel ?? "", /^2 个线程 · 最近:发布回滚$/); + assert.equal(folder?.latestReplyAt, "2026-04-05T11:00:00+08:00"); + assert.equal(folder?.contextBudgetIndicator.percent, 18); + assert.equal(folder?.contextBudgetIndicator.level, "critical"); + assert.equal(folder?.mustFinishBeforeCompaction, true); +}); +``` + +- [ ] **Step 2: 跑测试,确认先失败** + +Run: + +```bash +npx --yes tsx --test /Users/kris/code/boss/tests/conversation-home-items.test.ts +``` + +Expected: +- FAIL,提示 `folderLabel`、`preview`、`projectTitle` 或上下文环来源与预期不一致 + +- [ ] **Step 3: 在首页聚合里补全文件夹项来源规则** + +在 `/Users/kris/code/boss/src/lib/boss-projections.ts` 的 `getConversationHomeItems()` 中,把当前文件夹项构造收成: + +```ts +const latestItem = [...items].sort((a, b) => b.latestReplyAt.localeCompare(a.latestReplyAt))[0]; +const topContextItem = [...items] + .filter((item) => item.contextBudgetIndicator.visible) + .sort((a, b) => { + if (a.mustFinishBeforeCompaction !== b.mustFinishBeforeCompaction) { + return a.mustFinishBeforeCompaction ? -1 : 1; + } + const aLevel = a.contextBudgetIndicator.level ?? "safe"; + const bLevel = b.contextBudgetIndicator.level ?? "safe"; + if (levelPriority[aLevel] !== levelPriority[bLevel]) { + return levelPriority[aLevel] - levelPriority[bLevel]; + } + return b.latestReplyAt.localeCompare(a.latestReplyAt); + })[0] ?? latestItem; + +const recentThreadLabel = latestItem.threadTitle?.trim(); +const folderSubtitle = recentThreadLabel + ? `${items.length} 个线程 · 最近:${recentThreadLabel}` + : `${items.length} 个线程`; + +passthrough.push({ + conversationId: `folder-${folderKey}`, + conversationType: "folder_archive", + projectId: folderKey, + projectTitle: latestItem.projectTitle, + threadTitle: project?.threadMeta?.folderName?.trim() || project?.name || latestItem.folderLabel || "项目文件夹", + folderLabel: folderSubtitle, + folderKey, + threadCount: items.length, + preview: latestItem.preview, + lastMessagePreview: latestItem.lastMessagePreview, + activityIconCount: Math.max(...items.map((item) => item.activityIconCount)), + topPinnedLabel: items.some((item) => item.topPinnedLabel) ? "置顶" : undefined, + manualPinned: items.some((item) => item.manualPinned), + latestReplyAt: latestItem.latestReplyAt, + latestReplyLabel: latestItem.latestReplyLabel, + unreadCount: items.reduce((sum, item) => sum + item.unreadCount, 0), + riskLevel: topContextItem.riskLevel, + activeDeviceCount: latestItem.activeDeviceCount, + deviceNamesPreview: latestItem.deviceNamesPreview, + avatar: latestItem.avatar, + contextBudgetIndicator: topContextItem.contextBudgetIndicator, + contextBudgetSourceNodeId: topContextItem.contextBudgetSourceNodeId, + contextBudgetUpdatedAt: topContextItem.contextBudgetUpdatedAt, + mustFinishBeforeCompaction: topContextItem.mustFinishBeforeCompaction, +}); +``` + +- [ ] **Step 4: 再补两个边界测试,锁住 1 ↔ 2+ 升降级** + +把下面两个测试补到 `/Users/kris/code/boss/tests/conversation-home-items.test.ts`: + +```ts +test("single thread project stays direct until a second thread appears", async () => { + await setup(); + const state = await readState(); + + state.projects = state.projects.filter((project) => project.id === "master-agent"); + state.projects.push( + buildImportedThreadProject( + "mac-studio", + "talking-thread-1", + "Talking", + "talking", + "树莓派二代查询", + "thread-1", + "2026-04-05T12:00:00+08:00", + ), + ); + + let items = getConversationHomeItems(state); + assert.equal(items.some((item) => item.projectId === "talking-thread-1"), true); + assert.equal(items.some((item) => item.conversationType === "folder_archive"), false); + + state.projects.push( + buildImportedThreadProject( + "mac-studio", + "talking-thread-2", + "Talking", + "talking", + "语音桥接回归", + "thread-2", + "2026-04-05T12:05:00+08:00", + ), + ); + + items = getConversationHomeItems(state); + assert.equal(items.some((item) => item.projectId === "talking-thread-1"), false); + assert.equal(items.some((item) => item.projectId === "talking-thread-2"), false); + assert.equal(items.some((item) => item.folderKey === "mac-studio:talking"), true); +}); + +test("folder archive falls back to direct thread when project shrinks back to one thread", async () => { + await setup(); + const state = await readState(); + + state.projects = state.projects.filter((project) => project.id === "master-agent"); + const first = buildImportedThreadProject("mac-studio", "talking-thread-1", "Talking", "talking", "树莓派二代查询", "thread-1", "2026-04-05T12:00:00+08:00"); + const second = buildImportedThreadProject("mac-studio", "talking-thread-2", "Talking", "talking", "语音桥接回归", "thread-2", "2026-04-05T12:05:00+08:00"); + state.projects.push(first, second); + state.projects = state.projects.filter((project) => project.id !== "talking-thread-2"); + + const items = getConversationHomeItems(state); + const direct = items.find((item) => item.projectId === "talking-thread-1"); + + assert.ok(direct); + assert.equal(direct?.conversationType, "single_device"); + assert.equal(items.some((item) => item.folderKey === "mac-studio:talking"), false); +}); +``` + +- [ ] **Step 5: 重新跑投影测试,确认通过** + +Run: + +```bash +npx --yes tsx --test /Users/kris/code/boss/tests/conversation-home-items.test.ts +``` + +Expected: +- PASS + +- [ ] **Step 6: 提交这一小步** + +```bash +git add /Users/kris/code/boss/src/lib/boss-projections.ts /Users/kris/code/boss/tests/conversation-home-items.test.ts +git commit -m "feat: tighten conversation folder archive projections" +``` + +### Task 2: 收平 Web 首页与搜索的文件夹心智 + +**Files:** +- Modify: `/Users/kris/code/boss/src/components/app-ui.tsx` +- Test: `/Users/kris/code/boss/tests/conversation-home-items.test.ts` + +- [ ] **Step 1: 先补失败测试,锁住 Web 文件夹副标题和搜索结果标签** + +在 `/Users/kris/code/boss/tests/conversation-home-items.test.ts` 里新增一个只关心文案拼装的小测试: + +```ts +test("folder archive subtitle stays compact and search label preserves project and thread", 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-1", "Boss", "boss", "Android UI 收尾", "thread-1", "2026-04-05T10:00:00+08:00"), + buildImportedThreadProject("mac-studio", "boss-thread-2", "Boss", "boss", "发布回滚", "thread-2", "2026-04-05T11:00:00+08:00"), + ); + + const [folder] = getConversationHomeItems(state).filter((item) => item.conversationType === "folder_archive"); + + assert.equal(folder?.folderLabel, "2 个线程 · 最近:发布回滚"); + assert.equal(folder?.threadTitle, "Boss"); +}); +``` + +- [ ] **Step 2: 跑测试,确认首页文案或搜索标签尚未完全匹配** + +Run: + +```bash +npx --yes tsx --test /Users/kris/code/boss/tests/conversation-home-items.test.ts +``` + +Expected: +- FAIL,或当前文案与计划不一致 + +- [ ] **Step 3: 调整 Web 首页文件夹项文案和搜索命中展示** + +在 `/Users/kris/code/boss/src/components/app-ui.tsx` 的会话列表渲染里,保持首页仍只吃 `ConversationItem[]`,但把文案收成: + +```tsx +const title = + conversation.conversationType === "folder_archive" + ? conversation.threadTitle + : conversation.projectTitle; + +const subtitle = + conversation.conversationType === "folder_archive" + ? conversation.folderLabel + : conversation.lastMessagePreview; +``` + +并在搜索结果命中多线程项目线程时,展示: + +```tsx +const searchLabel = + conversation.conversationType === "folder_archive" + ? conversation.folderLabel + : conversation.folderKey && conversation.folderLabel + ? `${conversation.folderLabel} / ${conversation.threadTitle}` + : conversation.threadTitle; +``` + +同时把多线程项目的点击保持到文件夹页: + +```tsx +const href = + conversation.conversationType === "folder_archive" && conversation.folderKey + ? `/conversations/folders/${encodeURIComponent(conversation.folderKey)}` + : `/conversations/${encodeURIComponent(conversation.projectId)}`; +``` + +- [ ] **Step 4: 重新跑投影测试,确认首页文案没回退** + +Run: + +```bash +npx --yes tsx --test /Users/kris/code/boss/tests/conversation-home-items.test.ts +``` + +Expected: +- PASS + +- [ ] **Step 5: 提交这一小步** + +```bash +git add /Users/kris/code/boss/src/components/app-ui.tsx /Users/kris/code/boss/tests/conversation-home-items.test.ts +git commit -m "feat: align web conversation folders with drawer design" +``` + +### Task 3: 收平 Android 首页文件夹渲染与搜索 + +**Files:** +- Modify: `/Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/MainActivity.java` +- Modify: `/Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java` +- Test: `/Users/kris/code/boss/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSearchTest.java` +- Test: `/Users/kris/code/boss/android/app/src/test/java/com/hyzq/boss/MainActivityPinnedConversationsTest.java` +- Test: `/Users/kris/code/boss/android/app/src/test/java/com/hyzq/boss/MainActivityRootListAdapterTest.java` + +- [ ] **Step 1: 先写失败测试,锁住多线程项目在首页显示为文件夹而不是平铺线程** + +在 `/Users/kris/code/boss/android/app/src/test/java/com/hyzq/boss/MainActivityRootListAdapterTest.java` 增加: + +```java +@Test +public void folderArchiveRowUsesProjectTitleAndCompactSubtitle() throws Exception { + JSONObject folderItem = new JSONObject() + .put("conversationType", "folder_archive") + .put("projectId", "mac-studio:boss") + .put("projectTitle", "Boss") + .put("threadTitle", "Boss") + .put("folderLabel", "2 个线程 · 最近:发布回滚") + .put("threadCount", 2) + .put("preview", "最近:发布回滚") + .put("latestReplyLabel", "11:00") + .put("activityIconCount", 0) + .put("contextBudgetIndicator", new JSONObject() + .put("visible", true) + .put("style", "ring_percent") + .put("percent", 18) + .put("level", "critical")); + + WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(folderItem); + + assertEquals("Boss", row.threadTitle); + assertEquals("2 个线程 · 最近:发布回滚", row.metaLine); + assertEquals("最近:发布回滚", row.preview); +} +``` + +- [ ] **Step 2: 写失败测试,锁住搜索命中多线程项目线程时保留项目归属** + +在 `/Users/kris/code/boss/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSearchTest.java` 增加: + +```java +@Test +public void searchResultsKeepFolderProjectContextForThreadsInsideArchives() throws Exception { + TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class) + .setup() + .get(); + + activity.setConversationItems(new JSONArray() + .put(new JSONObject() + .put("conversationType", "folder_archive") + .put("projectId", "mac-studio:boss") + .put("projectTitle", "Boss") + .put("threadTitle", "Boss") + .put("folderKey", "mac-studio:boss") + .put("folderLabel", "2 个线程 · 最近:发布回滚") + .put("preview", "最近:发布回滚")) + .put(new JSONObject() + .put("conversationType", "single_device") + .put("projectId", "boss-thread-2") + .put("projectTitle", "发布回滚") + .put("threadTitle", "发布回滚") + .put("folderKey", "mac-studio:boss") + .put("folderLabel", "Boss") + .put("preview", "最近:发布回滚"))); + + activity.enterConversationSearchModeForTest(); + activity.setConversationSearchQueryForTest("发布"); + + assertTrue(activity.latestRenderedSearchLabels().contains("Boss / 发布回滚")); +} +``` + +- [ ] **Step 3: 跑 Android 定向测试,确认先失败** + +Run: + +```bash +cd /Users/kris/code/boss/android && ./gradlew testDebugUnitTest --tests com.hyzq.boss.MainActivityConversationSearchTest --tests com.hyzq.boss.MainActivityRootListAdapterTest --tests com.hyzq.boss.MainActivityPinnedConversationsTest --no-daemon +``` + +Expected: +- FAIL,提示行文案或搜索结果标签与新预期不一致 + +- [ ] **Step 4: 调整 Android 首页行文案和搜索标签** + +在 `/Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`,让 `folder_archive` 行走: + +```java +if ("folder_archive".equals(type)) { + return new ConversationRow( + item.optString("threadTitle", item.optString("projectTitle", "项目文件夹")), + item.optString("folderLabel", ""), + item.optString("preview", ""), + item.optString("latestReplyLabel", "刚刚"), + contextStatus, + activityVisible, + activityIconCount, + avatarInitials + ); +} +``` + +在 `/Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/MainActivity.java` 的搜索结果构建里,给多线程项目内线程加归属标签: + +```java +private String buildConversationSearchLabel(JSONObject item) { + String conversationType = item.optString("conversationType", ""); + if ("folder_archive".equals(conversationType)) { + return item.optString("threadTitle", item.optString("projectTitle", "项目文件夹")); + } + String folderLabel = item.optString("folderLabel", "").trim(); + String folderKey = item.optString("folderKey", "").trim(); + String threadTitle = item.optString("threadTitle", item.optString("projectTitle", "会话")); + if (!folderLabel.isEmpty() && !folderKey.isEmpty()) { + return folderLabel + " / " + threadTitle; + } + return threadTitle; +} +``` + +并保证点击搜索结果时: + +```java +if (!folderKey.isEmpty() && !"folder_archive".equals(conversationType) && isArchivedProjectThread(item)) { + openConversationFolder(folderKey, folderLabel, projectId); + return; +} +``` + +- [ ] **Step 5: 重新跑 Android 定向测试,确认通过** + +Run: + +```bash +cd /Users/kris/code/boss/android && ./gradlew testDebugUnitTest --tests com.hyzq.boss.MainActivityConversationSearchTest --tests com.hyzq.boss.MainActivityRootListAdapterTest --tests com.hyzq.boss.MainActivityPinnedConversationsTest --no-daemon +``` + +Expected: +- PASS + +- [ ] **Step 6: 提交这一小步** + +```bash +git add /Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/MainActivity.java /Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java /Users/kris/code/boss/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSearchTest.java /Users/kris/code/boss/android/app/src/test/java/com/hyzq/boss/MainActivityRootListAdapterTest.java /Users/kris/code/boss/android/app/src/test/java/com/hyzq/boss/MainActivityPinnedConversationsTest.java +git commit -m "feat: align android conversation folders with drawer design" +``` + +### Task 4: 把 Android 文件夹页收成项目内部线程页 + +**Files:** +- Modify: `/Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java` +- Test: `/Users/kris/code/boss/android/app/src/test/java/com/hyzq/boss/ConversationFolderActivityTest.java` + +- [ ] **Step 1: 写失败测试,锁住文件夹页标题、副标题和线程列表顺序** + +在 `/Users/kris/code/boss/android/app/src/test/java/com/hyzq/boss/ConversationFolderActivityTest.java` 增加: + +```java +@Test +public void folderScreenBehavesAsProjectThreadPage() throws Exception { + Intent intent = new Intent(ApplicationProvider.getApplicationContext(), TestConversationFolderActivity.class) + .putExtra(ConversationFolderActivity.EXTRA_FOLDER_KEY, "mac-studio:boss") + .putExtra(ConversationFolderActivity.EXTRA_FOLDER_NAME, "Boss"); + + TestConversationFolderActivity activity = Robolectric + .buildActivity(TestConversationFolderActivity.class, intent) + .setup() + .get(); + + activity.renderFolderForTest(new JSONObject() + .put("folderLabel", "Boss") + .put("deviceName", "Mac Studio") + .put("threadCount", 2) + .put("threads", new JSONArray() + .put(new JSONObject() + .put("projectId", "boss-thread-2") + .put("conversationType", "single_device") + .put("projectTitle", "发布回滚") + .put("threadTitle", "发布回滚") + .put("preview", "最近:发布回滚") + .put("latestReplyLabel", "11:00")) + .put(new JSONObject() + .put("projectId", "boss-thread-1") + .put("conversationType", "single_device") + .put("projectTitle", "Android UI 收尾") + .put("threadTitle", "Android UI 收尾") + .put("preview", "最近:Android UI 收尾") + .put("latestReplyLabel", "10:00")))); + + assertEquals("Boss", activity.topTitleText()); + assertEquals("2 个线程", activity.topSubtitleText()); + assertEquals(Arrays.asList("发布回滚", "Android UI 收尾"), activity.renderedThreadTitles()); +} +``` + +- [ ] **Step 2: 跑文件夹页定向测试,确认先失败** + +Run: + +```bash +cd /Users/kris/code/boss/android && ./gradlew testDebugUnitTest --tests com.hyzq.boss.ConversationFolderActivityTest --no-daemon +``` + +Expected: +- FAIL,提示标题、副标题或线程列表语义与新预期不一致 + +- [ ] **Step 3: 把文件夹页标题和副标题改成项目内部线程页** + +在 `/Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java` 调整: + +```java +String resolvedFolderName = folder.optString("folderLabel", folderName == null ? "项目线程" : folderName); +int threadCount = folder.optInt("threadCount", 0); +configureScreen(resolvedFolderName, threadCount + " 个线程"); + +appendContent(BossUi.buildSoftPanel( + this, + resolvedFolderName, + threadCount + " 个线程", + "选择线程后进入具体聊天窗口。" +)); +``` + +并在接收搜索跳转时支持可选的目标线程定位: + +```java +public static final String EXTRA_TARGET_PROJECT_ID = "target_project_id"; +``` + +在渲染线程列表时,若命中目标线程: + +```java +if (projectId.equals(targetProjectId)) { + row = row.withHighlighted(true); +} +``` + +如果当前 `ConversationRow` 没有高亮能力,本轮先用滚动到命中线程代替: + +```java +if (projectId.equals(targetProjectId)) { + matchedThreadView = rowView; +} +... +if (matchedThreadView != null) { + matchedThreadView.post(() -> matchedThreadView.requestFocus()); +} +``` + +- [ ] **Step 4: 重新跑文件夹页测试,确认通过** + +Run: + +```bash +cd /Users/kris/code/boss/android && ./gradlew testDebugUnitTest --tests com.hyzq.boss.ConversationFolderActivityTest --no-daemon +``` + +Expected: +- PASS + +- [ ] **Step 5: 提交这一小步** + +```bash +git add /Users/kris/code/boss/android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java /Users/kris/code/boss/android/app/src/test/java/com/hyzq/boss/ConversationFolderActivityTest.java +git commit -m "feat: refine android folder conversation screen" +``` + +### Task 5: 文档、全量验证与发布前检查 + +**Files:** +- Modify: `/Users/kris/code/boss/README.md` +- Modify: `/Users/kris/code/boss/docs/architecture/current_runtime_and_deploy_status_cn.md` + +- [ ] **Step 1: 更新 README,明确首页会话归档规则** + +在 `/Users/kris/code/boss/README.md` 增加一段简洁说明: + +```md +## 会话首页项目归档规则 + +- 单线程项目:首页直接显示线程会话 +- 2 个及以上线程的项目:首页显示为一个项目文件夹会话 +- 文件夹项时间取项目内最新活跃线程 +- 文件夹项预览取项目内最新回复 +- 文件夹项上下文环取项目内最需要关注的线程 +- 搜索仍可命中线程,但多线程项目中的线程会先进入对应项目文件夹页 +``` + +- [ ] **Step 2: 更新运行状态文档,记录 Android/Web 均已收成项目文件夹心智** + +在 `/Users/kris/code/boss/docs/architecture/current_runtime_and_deploy_status_cn.md` 补一段: + +```md +### 会话首页项目文件夹模式 + +- 单线程项目直接显示线程 +- 多线程项目在首页聚合为项目文件夹会话 +- Android 与 Web 均使用相同聚合规则 +- 会话搜索保留线程可达性,但不再打破首页项目文件夹结构 +``` + +- [ ] **Step 3: 跑最终验证** + +Run: + +```bash +cd /Users/kris/code/boss && npx --yes tsx --test tests/conversation-home-items.test.ts +cd /Users/kris/code/boss/android && ./gradlew testDebugUnitTest --tests com.hyzq.boss.MainActivityConversationSearchTest --tests com.hyzq.boss.MainActivityPinnedConversationsTest --tests com.hyzq.boss.MainActivityRootListAdapterTest --tests com.hyzq.boss.ConversationFolderActivityTest --no-daemon +cd /Users/kris/code/boss && npm run lint +cd /Users/kris/code/boss && npm run build +cd /Users/kris/code/boss/android && ./gradlew assembleRelease --no-daemon +``` + +Expected: +- 全部 PASS +- 没有新的 lint/build 回归 + +- [ ] **Step 4: 提交文档和验证收口** + +```bash +git add /Users/kris/code/boss/README.md /Users/kris/code/boss/docs/architecture/current_runtime_and_deploy_status_cn.md +git commit -m "docs: document conversation folder drawer behavior" +``` + +## Self-Review + +- Spec coverage: + - 首页单线程直显、多线程项目文件夹化:Task 1 / Task 3 + - 文件夹项时间、预览、上下文环聚合:Task 1 + - 搜索保留线程可达性但不打破文件夹心智:Task 2 / Task 3 + - 文件夹页作为项目内部线程页:Task 4 + - 置顶、排序、1 ↔ 2+ 升降级:Task 1 / Task 3 + - 文档收口:Task 5 +- Placeholder scan: + - 已避免 `TODO/TBD/自行处理` + - 每个任务都给了具体文件、测试和命令 +- Type consistency: + - 统一使用现有 `ConversationItem`、`folder_archive`、`folderKey`、`threadCount` + - Android 继续沿用 `WechatSurfaceMapper.ConversationRow` 和 `ConversationFolderActivity` +