From 6956d1ac78935044e9d06411a812188ec930dad3 Mon Sep 17 00:00:00 2001 From: kris Date: Mon, 6 Apr 2026 05:35:42 +0800 Subject: [PATCH] fix: complete folder archive action handling --- .../hyzq/boss/ConversationFolderActivity.java | 7 +- .../boss/ConversationFolderActivityTest.java | 26 +++ .../hyzq/boss/MainActivityRealtimeTest.java | 120 ++++++++++++++ src/components/app-ui.tsx | 25 +-- src/lib/boss-data.ts | 40 ++++- src/lib/boss-projections.ts | 6 +- tests/conversation-home-items.test.ts | 152 ++++++++++++++++++ 7 files changed, 352 insertions(+), 24 deletions(-) diff --git a/android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java b/android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java index f976533..f4bf530 100644 --- a/android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java @@ -163,20 +163,19 @@ public class ConversationFolderActivity extends BossScreenActivity { targetIndices.add(i); } } - return targetIndices; } - if (targetProjectId != null && !targetProjectId.isEmpty()) { + if (targetIndices.isEmpty() && targetProjectId != null && !targetProjectId.isEmpty()) { for (int i = 0; i < threads.length(); i++) { JSONObject item = threads.optJSONObject(i); if (item != null && targetProjectId.equals(item.optString("projectId", ""))) { targetIndices.add(i); - return targetIndices; + break; } } } - if (targetProjectLabel != null && !targetProjectLabel.isEmpty()) { + if (targetIndices.isEmpty() && targetProjectLabel != null && !targetProjectLabel.isEmpty()) { for (int i = 0; i < threads.length(); i++) { JSONObject item = threads.optJSONObject(i); if (item != null && targetProjectLabel.equals(item.optString("threadTitle", ""))) { diff --git a/android/app/src/test/java/com/hyzq/boss/ConversationFolderActivityTest.java b/android/app/src/test/java/com/hyzq/boss/ConversationFolderActivityTest.java index c7caa16..fc980da 100644 --- a/android/app/src/test/java/com/hyzq/boss/ConversationFolderActivityTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ConversationFolderActivityTest.java @@ -91,6 +91,32 @@ public class ConversationFolderActivityTest { assertEquals(0, countTextOccurrences(content, "project-1")); } + @Test + public void conversationFolderFallsBackFromMissingSearchTargetsToProjectIdThenLabel() throws Exception { + Intent intent = new Intent() + .putExtra(ConversationFolderActivity.EXTRA_FOLDER_KEY, "talking") + .putExtra(ConversationFolderActivity.EXTRA_FOLDER_NAME, "Talking") + .putExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_ID, "project-3") + .putExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_IDS, new String[]{"project-99", "project-100"}) + .putExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_LABEL, "日志收口"); + TestConversationFolderActivity activity = Robolectric + .buildActivity(TestConversationFolderActivity.class, intent) + .setup() + .get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderFolder", + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildFolderPayload()) + ); + + LinearLayout content = activity.findViewById(R.id.screen_content); + assertTrue(viewTreeContainsText(content, "已定位到目标线程")); + assertTrue(viewTreeContainsText(content, "日志收口")); + assertEquals(0, countTextOccurrences(content, "project-99")); + assertEquals(1, countTextOccurrences(content, "目标线程")); + } + private static JSONObject buildFolderPayload() throws Exception { JSONArray threads = new JSONArray() .put(new JSONObject() diff --git a/android/app/src/test/java/com/hyzq/boss/MainActivityRealtimeTest.java b/android/app/src/test/java/com/hyzq/boss/MainActivityRealtimeTest.java index 7d4194a..65f0e08 100644 --- a/android/app/src/test/java/com/hyzq/boss/MainActivityRealtimeTest.java +++ b/android/app/src/test/java/com/hyzq/boss/MainActivityRealtimeTest.java @@ -14,6 +14,7 @@ import org.robolectric.Shadows; import org.robolectric.annotation.Config; import org.robolectric.util.ReflectionHelpers; +import java.io.IOException; import java.util.function.BooleanSupplier; @RunWith(RobolectricTestRunner.class) @@ -260,6 +261,27 @@ public class MainActivityRealtimeTest { assertEquals("flat-thread", conversationsData.optJSONObject(0).optString("projectId", "")); } + @Test + public void refreshConversationsData_fallsBackToFlatConversationsFeedWhenHomeFeedThrowsIOException() throws Exception { + MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get(); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + RecordingIOExceptionConversationSourceClient apiClient = new RecordingIOExceptionConversationSourceClient( + activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE) + ); + ReflectionHelpers.setField(activity, "apiClient", apiClient); + ReflectionHelpers.callInstanceMethod(activity, "showContent"); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + activity.refreshConversationsData(); + waitFor(() -> apiClient.homeCalls > 0 && apiClient.conversationsCalls > 0); + + assertEquals(1, apiClient.homeCalls); + assertEquals(1, apiClient.conversationsCalls); + JSONArray conversationsData = ReflectionHelpers.getField(activity, "conversationsData"); + assertEquals("flat-thread", conversationsData.optJSONObject(0).optString("projectId", "")); + } + @Test public void refreshAllData_prefersConversationHomeFeedOverFlatConversationsFeed() throws Exception { MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get(); @@ -308,6 +330,31 @@ public class MainActivityRealtimeTest { assertEquals("flat-thread", conversationsData.optJSONObject(0).optString("projectId", "")); } + @Test + public void refreshAllData_fallsBackToFlatConversationsFeedWhenHomeFeedThrowsIOException() throws Exception { + MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get(); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + RecordingIOExceptionConversationSourceClient apiClient = new RecordingIOExceptionConversationSourceClient( + activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE) + ); + ReflectionHelpers.setField(activity, "apiClient", apiClient); + ReflectionHelpers.callInstanceMethod(activity, "showContent"); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + ReflectionHelpers.callInstanceMethod( + activity, + "refreshAllData", + ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject()) + ); + waitFor(() -> apiClient.homeCalls > 0 && apiClient.conversationsCalls > 0); + + assertEquals(1, apiClient.homeCalls); + assertEquals(1, apiClient.conversationsCalls); + JSONArray conversationsData = ReflectionHelpers.getField(activity, "conversationsData"); + assertEquals("flat-thread", conversationsData.optJSONObject(0).optString("projectId", "")); + } + private static void waitFor(BooleanSupplier condition) throws Exception { long deadlineAt = System.currentTimeMillis() + 2_000L; while (System.currentTimeMillis() < deadlineAt) { @@ -506,4 +553,77 @@ public class MainActivityRealtimeTest { .put("latestReplyLabel", "11:00")); } } + + private static final class RecordingIOExceptionConversationSourceClient extends BossApiClient { + int homeCalls; + int conversationsCalls; + int sessionCalls; + int devicesCalls; + int settingsCalls; + int otaCalls; + + RecordingIOExceptionConversationSourceClient(android.content.SharedPreferences prefs) { + super(prefs, "https://boss.hyzq.net"); + } + + @Override + public ApiResponse getConversationHome() throws IOException, org.json.JSONException { + homeCalls += 1; + throw new IOException("HOME_TIMEOUT"); + } + + @Override + public ApiResponse getConversations() throws IOException, org.json.JSONException { + conversationsCalls += 1; + return new ApiResponse(200, new JSONObject() + .put("ok", true) + .put("conversations", buildFlatConversations())); + } + + @Override + public ApiResponse getSession() throws IOException, org.json.JSONException { + sessionCalls += 1; + return new ApiResponse(200, new JSONObject() + .put("ok", true) + .put("session", new JSONObject() + .put("account", "17600003315") + .put("displayName", "Boss 超级管理员"))); + } + + @Override + public ApiResponse getDevices() throws IOException, org.json.JSONException { + devicesCalls += 1; + return new ApiResponse(200, new JSONObject() + .put("ok", true) + .put("devices", new JSONArray())); + } + + @Override + public ApiResponse getSettings() throws IOException, org.json.JSONException { + settingsCalls += 1; + return new ApiResponse(200, new JSONObject() + .put("ok", true) + .put("settings", new JSONObject().put("preferredEntryPoint", "conversations")) + .put("user", new JSONObject())); + } + + @Override + public ApiResponse getOtaStatus() throws IOException, org.json.JSONException { + otaCalls += 1; + return new ApiResponse(200, new JSONObject() + .put("ok", true) + .put("hasOta", false)); + } + + private static JSONArray buildFlatConversations() throws org.json.JSONException { + return new JSONArray().put(new JSONObject() + .put("projectId", "flat-thread") + .put("conversationType", "single_device") + .put("projectTitle", "发布回滚") + .put("threadTitle", "发布回滚") + .put("folderLabel", "Boss") + .put("lastMessagePreview", "最近:发布回滚") + .put("latestReplyLabel", "11:00")); + } + } } diff --git a/src/components/app-ui.tsx b/src/components/app-ui.tsx index 2f0f6fb..b525cec 100644 --- a/src/components/app-ui.tsx +++ b/src/components/app-ui.tsx @@ -363,8 +363,16 @@ export function getConversationListItemPresentation(conversation: ConversationIt }; } -function conversationActionsPath(projectId: string) { - return `/api/v1/conversations/${projectId}/actions`; +export function getConversationActionAvailability(conversation: ConversationItem) { + const canTogglePin = conversation.projectId !== "master-agent"; + return { + canTogglePin, + togglePinLabel: conversation.topPinnedLabel || conversation.manualPinned ? "取消置顶" : "置顶", + }; +} + +export function getConversationActionsPath(projectId: string) { + return `/api/v1/conversations/${encodeURIComponent(projectId)}/actions`; } function ConversationActionButtons({ @@ -374,14 +382,11 @@ function ConversationActionButtons({ }) { const router = useRouter(); const [loading, setLoading] = useState<"toggle_pin" | "mark_read" | null>(null); - - if (conversation.conversationType === "folder_archive") { - return
; - } + const actionAvailability = getConversationActionAvailability(conversation); async function runAction(action: "toggle_pin" | "mark_read") { setLoading(action); - await fetch(conversationActionsPath(conversation.projectId), { + await fetch(getConversationActionsPath(conversation.projectId), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action }), @@ -392,14 +397,14 @@ function ConversationActionButtons({ return (
- {conversation.projectId !== "master-agent" ? ( + {actionAvailability.canTogglePin ? ( ) : null} {conversation.unreadCount > 0 ? ( @@ -477,7 +482,7 @@ export function ConversationList({
{conversation.projectId === "master-agent" ? "置顶" - : conversation.manualPinned + : conversation.topPinnedLabel ? "置顶" : ""}
diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index 8e42e96..3268369 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -8336,26 +8336,52 @@ export async function updateConversationAction( action: "toggle_pin" | "mark_read", ) { const project = await mutateState((state) => { - const nextProject = state.projects.find((item) => item.id === projectId); - if (!nextProject) throw new Error("PROJECT_NOT_FOUND"); + const directProject = state.projects.find((item) => item.id === projectId); + const folderProjects = directProject ? [] : state.projects.filter((item) => buildProjectFolderKey(item) === projectId); + if (!directProject && folderProjects.length === 0) throw new Error("PROJECT_NOT_FOUND"); if (action === "toggle_pin") { - if (nextProject.systemPinned) { - throw new Error("MASTER_PROJECT_PIN_LOCKED"); + if (directProject) { + if (directProject.systemPinned) { + throw new Error("MASTER_PROJECT_PIN_LOCKED"); + } + directProject.pinned = !directProject.pinned; + } else { + const folderLocked = folderProjects.some((item) => item.systemPinned); + if (folderLocked) { + throw new Error("MASTER_PROJECT_PIN_LOCKED"); + } + const folderPinned = folderProjects.some((item) => item.pinned || item.systemPinned); + for (const item of folderProjects) { + item.pinned = !folderPinned; + } } - nextProject.pinned = !nextProject.pinned; } if (action === "mark_read") { - nextProject.unreadCount = 0; + if (directProject) { + directProject.unreadCount = 0; + } else { + for (const item of folderProjects) { + item.unreadCount = 0; + } + } } - return nextProject; + return directProject ?? folderProjects[0]; }); publishBossEvent("conversation.updated", { projectId }); return project; } +function buildProjectFolderKey(project: Project) { + if (project.id === "master-agent" || project.isGroup) return undefined; + const deviceId = project.deviceIds[0]; + const folderRef = (project.threadMeta.codexFolderRef?.trim() || project.threadMeta.folderName.trim()).toLowerCase(); + if (!deviceId || !folderRef) return undefined; + return `${deviceId}:${folderRef}`; +} + export async function renameProjectThread(input: { projectId: string; threadDisplayName: string; diff --git a/src/lib/boss-projections.ts b/src/lib/boss-projections.ts index 2a4e979..bec59a5 100644 --- a/src/lib/boss-projections.ts +++ b/src/lib/boss-projections.ts @@ -196,7 +196,7 @@ function projectType(project: Project): ConversationItem["conversationType"] { function buildFolderKey(project: Project) { if (project.id === "master-agent" || project.isGroup) return undefined; const deviceId = project.deviceIds[0]; - const folderRef = project.threadMeta.codexFolderRef?.trim() || project.threadMeta.folderName.trim(); + const folderRef = (project.threadMeta.codexFolderRef?.trim() || project.threadMeta.folderName.trim()).toLowerCase(); if (!deviceId || !folderRef) return undefined; return `${deviceId}:${folderRef}`; } @@ -561,6 +561,8 @@ export function getConversationHomeItems(state: BossState): ConversationItem[] { folderLabel: recentThreadLabel ? `${items.length} 个线程 · 最近:${recentThreadLabel}` : `${items.length} 个线程`, folderKey, threadCount: items.length, + topPinnedLabel: items.some((entry) => entry.topPinnedLabel) ? "置顶" : undefined, + manualPinned: items.some((entry) => entry.manualPinned), ...(searchAliases ? { searchAliases: searchAliases.aliases, @@ -574,8 +576,6 @@ export function getConversationHomeItems(state: BossState): ConversationItem[] { latestItem.preview || `包含 ${items.length} 个线程,最近活跃:《${latestItem.threadTitle}》`, activityIconCount: Math.max(0, Math.min(4, items.reduce((sum, entry) => sum + entry.activityIconCount, 0))), - manualPinned: false, - topPinnedLabel: undefined, latestReplyAt: latestItem.latestReplyAt, latestReplyLabel: latestItem.latestReplyLabel, unreadCount: items.reduce((sum, entry) => sum + entry.unreadCount, 0), diff --git a/tests/conversation-home-items.test.ts b/tests/conversation-home-items.test.ts index e7b581c..4651c1c 100644 --- a/tests/conversation-home-items.test.ts +++ b/tests/conversation-home-items.test.ts @@ -6,10 +6,13 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; let runtimeRoot = ""; let readState: (typeof import("../src/lib/boss-data"))["readState"]; +let updateConversationAction: (typeof import("../src/lib/boss-data"))["updateConversationAction"]; let getConversationHomeItems: (typeof import("../src/lib/boss-projections"))["getConversationHomeItems"]; let getConversationFolderView: (typeof import("../src/lib/boss-projections"))["getConversationFolderView"]; let formatTimestampLabel: (typeof import("../src/lib/boss-projections"))["formatTimestampLabel"]; let getConversationListItemPresentation: (typeof import("../src/components/app-ui"))["getConversationListItemPresentation"]; +let getConversationActionAvailability: (typeof import("../src/components/app-ui"))["getConversationActionAvailability"]; +let getConversationActionsPath: (typeof import("../src/components/app-ui"))["getConversationActionsPath"]; async function setup() { if (runtimeRoot) return; @@ -23,10 +26,13 @@ async function setup() { import("../src/components/app-ui.tsx"), ]); readState = data.readState; + updateConversationAction = data.updateConversationAction; getConversationHomeItems = projections.getConversationHomeItems; getConversationFolderView = projections.getConversationFolderView; formatTimestampLabel = projections.formatTimestampLabel; getConversationListItemPresentation = ui.getConversationListItemPresentation; + getConversationActionAvailability = ui.getConversationActionAvailability; + getConversationActionsPath = ui.getConversationActionsPath; } test.after(async () => { @@ -298,6 +304,7 @@ test("conversation home upgrades and downgrades a folder archive as thread count "2026-04-04T10:00:00+08:00", ), ); + await writeFile(process.env.BOSS_STATE_FILE as string, `${JSON.stringify(state, null, 2)}\n`); let items = getConversationHomeItems(state); let direct = items.find((item) => item.projectId === "boss-thread-1"); @@ -338,6 +345,105 @@ test("conversation home upgrades and downgrades a folder archive as thread count ); }); +test("folder archive pin state follows child threads and folder toggle syncs all threads", 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", + "归档确认", + "thread-1", + "2026-04-04T10:00:00+08:00", + ), + pinned: true, + }, + buildImportedThreadProject( + "mac-studio", + "boss-thread-2", + "Boss", + "boss", + "发布回滚", + "thread-2", + "2026-04-04T11:00:00+08:00", + ), + ); + + const items = getConversationHomeItems(state); + let folder = items.find((item) => item.conversationType === "folder_archive" && item.folderKey === "mac-studio:boss"); + + assert.ok(folder, "expected folder archive once the folder has 2 threads"); + assert.equal(folder?.topPinnedLabel, "置顶"); + assert.equal(folder?.manualPinned, true); + + for (const project of state.projects.filter((project) => project.id.startsWith("boss-thread"))) { + project.pinned = false; + } + folder = getConversationHomeItems(state).find((item) => item.conversationType === "folder_archive" && item.folderKey === "mac-studio:boss"); + assert.ok(folder, "expected folder archive after unpinning folder"); + assert.equal(folder?.topPinnedLabel, undefined); + assert.equal(folder?.manualPinned, false); + + for (const project of state.projects.filter((project) => project.id.startsWith("boss-thread"))) { + project.pinned = true; + } + folder = getConversationHomeItems(state).find((item) => item.conversationType === "folder_archive" && item.folderKey === "mac-studio:boss"); + assert.ok(folder, "expected folder archive after restoring folder pin"); + assert.equal(folder?.topPinnedLabel, "置顶"); + assert.equal(folder?.manualPinned, true); +}); + +test("folder archive toggle_pin updates all threads that share the folder key", async () => { + await setup(); + const state = await readState(); + + state.projects = state.projects.filter((project) => project.id === "master-agent"); + state.projects.push( + buildImportedThreadProject( + "mac-studio", + "yuandi-thread-1", + "园地", + "/Users/kris/code/yuandi", + "线程一", + "thread-1", + "2026-04-05T10:00:00+08:00", + ), + buildImportedThreadProject( + "mac-studio", + "yuandi-thread-2", + "园地", + "/Users/kris/code/yuandi", + "线程二", + "thread-2", + "2026-04-05T11:00:00+08:00", + ), + ); + await writeFile(process.env.BOSS_STATE_FILE as string, `${JSON.stringify(state, null, 2)}\n`); + + await updateConversationAction("mac-studio:/users/kris/code/yuandi", "toggle_pin"); + let nextState = await readState(); + assert.deepEqual( + nextState.projects + .filter((project) => project.id.startsWith("yuandi-thread-")) + .map((project) => project.pinned), + [true, true], + ); + + await updateConversationAction("mac-studio:/users/kris/code/yuandi", "toggle_pin"); + nextState = await readState(); + assert.deepEqual( + nextState.projects + .filter((project) => project.id.startsWith("yuandi-thread-")) + .map((project) => project.pinned), + [false, false], + ); +}); + test("conversation home groups multiple imported threads by folder while keeping single-thread projects direct", async () => { await setup(); const state = await readState(); @@ -436,6 +542,52 @@ test("folder archive homepage rows keep the project title, compact subtitle, and assert.equal(presentation.href, "/conversations/folders/mac-studio%3Aboss"); }); +test("folder archive homepage rows expose pin toggles when the folder is pinned", 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", + "归档确认", + "thread-1", + "2026-03-30T11:00:00+08:00", + ), + pinned: true, + }, + buildImportedThreadProject( + "mac-studio", + "boss-thread-2", + "Boss", + "boss", + "发布回滚", + "thread-2", + "2026-03-30T12:00:00+08:00", + ), + ); + + const folder = getConversationHomeItems(state).find((item) => item.conversationType === "folder_archive"); + assert.ok(folder, "expected grouped folder archive item"); + + const actions = getConversationActionAvailability(folder!); + assert.equal(actions.canTogglePin, true); + assert.equal(actions.togglePinLabel, "取消置顶"); +}); + +test("folder archive action path encodes folder keys with nested path segments", async () => { + await setup(); + + assert.equal( + getConversationActionsPath("mac-studio:/Users/kris/code/yuandi"), + "/api/v1/conversations/mac-studio%3A%2FUsers%2Fkris%2Fcode%2Fyuandi/actions", + ); +}); + test("folder archive search aliases keep full reachability across five threads", async () => { await setup(); const state = await readState();