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();