fix: complete folder archive action handling
This commit is contained in:
@@ -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", ""))) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <div className="min-h-[24px]" />;
|
||||
}
|
||||
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 (
|
||||
<div className="flex items-center gap-2">
|
||||
{conversation.projectId !== "master-agent" ? (
|
||||
{actionAvailability.canTogglePin ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runAction("toggle_pin")}
|
||||
disabled={loading === "toggle_pin"}
|
||||
className="rounded-full border border-[#D9D9D9] px-3 py-1 text-[11px] text-[#57606A]"
|
||||
>
|
||||
{conversation.manualPinned ? "取消置顶" : "置顶"}
|
||||
{actionAvailability.togglePinLabel}
|
||||
</button>
|
||||
) : null}
|
||||
{conversation.unreadCount > 0 ? (
|
||||
@@ -477,7 +482,7 @@ export function ConversationList({
|
||||
<div className="min-h-[18px] text-[11px] text-[#07C160]">
|
||||
{conversation.projectId === "master-agent"
|
||||
? "置顶"
|
||||
: conversation.manualPinned
|
||||
: conversation.topPinnedLabel
|
||||
? "置顶"
|
||||
: ""}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user