diff --git a/src/lib/boss-projections.ts b/src/lib/boss-projections.ts index dde575d..653cd86 100644 --- a/src/lib/boss-projections.ts +++ b/src/lib/boss-projections.ts @@ -517,13 +517,17 @@ export function getConversationHomeItems(state: BossState): ConversationItem[] { 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 (a.contextBudgetIndicator.percent ?? 100) - (b.contextBudgetIndicator.percent ?? 100); + return b.latestReplyAt.localeCompare(a.latestReplyAt); })[0]; + const recentThreadLabel = latestItem.threadTitle.trim(); passthrough.push({ conversationId: `folder-${folderKey}`, conversationType: "folder_archive", @@ -534,7 +538,7 @@ export function getConversationHomeItems(state: BossState): ConversationItem[] { threadTitle: project?.threadMeta.folderName ?? (latestItem.folderLabel || latestItem.threadTitle), - folderLabel: `${device?.name ?? latestItem.deviceNamesPreview[0] ?? "设备"} · ${items.length} 个线程`, + folderLabel: recentThreadLabel ? `${items.length} 个线程 · 最近:${recentThreadLabel}` : `${items.length} 个线程`, folderKey, threadCount: items.length, preview: @@ -565,7 +569,9 @@ export function getConversationHomeItems(state: BossState): ConversationItem[] { percent: topContextItem?.contextBudgetIndicator.percent ?? 100, level: topContextItem?.contextBudgetIndicator.level ?? "safe", }, - mustFinishBeforeCompaction: items.some((item) => item.mustFinishBeforeCompaction), + contextBudgetSourceNodeId: topContextItem?.contextBudgetSourceNodeId, + contextBudgetUpdatedAt: topContextItem?.contextBudgetUpdatedAt, + mustFinishBeforeCompaction: Boolean(topContextItem?.mustFinishBeforeCompaction), }); } diff --git a/tests/conversation-home-items.test.ts b/tests/conversation-home-items.test.ts index 1db1101..5008381 100644 --- a/tests/conversation-home-items.test.ts +++ b/tests/conversation-home-items.test.ts @@ -65,6 +65,157 @@ function buildImportedThreadProject(deviceId: string, id: string, folderName: st }; } +function buildThreadContextSnapshot( + projectId: string, + threadId: string, + title: string, + contextBudgetLevel: "safe" | "watch" | "urgent" | "critical", + mustFinishBeforeCompaction: boolean, + contextBudgetRemainingPct: number, + capturedAt: string, + nodeId: string, +) { + return { + snapshotId: `${projectId}-${threadId}-snapshot`, + projectId, + taskId: `${projectId}-${threadId}-task`, + threadId, + title, + summary: `${title} 的线程状态`, + nodeId, + workerId: "worker-1", + sourceKind: "worker_estimator", + status: "context_urgent", + contextBudgetRemainingPct, + contextBudgetLevel, + mustFinishBeforeCompaction, + estimatedRemainingTurns: 4, + estimatedRemainingLargeMessages: 2, + compactionCount: 0, + patchPending: false, + testsPending: false, + evidencePending: false, + checklist: [], + capturedAt, + } satisfies import("../src/lib/boss-data").ThreadContextSnapshot; +} + +test("folder archives use the latest thread preview/time while subtitle and context ring come from the right 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-latest", + "Boss", + "boss", + "最新线程", + "thread-latest", + "2026-04-04T12:00:00+08:00", + ), + buildImportedThreadProject( + "mac-studio", + "boss-thread-urgent", + "Boss", + "boss", + "优先收尾", + "thread-urgent", + "2026-04-04T11:00:00+08:00", + ), + ); + state.threadContextSnapshots = [ + buildThreadContextSnapshot( + "boss-thread-latest", + "thread-latest", + "最新线程", + "critical", + false, + 12, + "2026-04-04T12:05:00+08:00", + "node-latest", + ), + buildThreadContextSnapshot( + "boss-thread-urgent", + "thread-urgent", + "优先收尾", + "critical", + true, + 87, + "2026-04-04T11:05:00+08:00", + "node-urgent", + ), + ]; + + const folder = getConversationHomeItems(state).find((item) => item.conversationType === "folder_archive"); + + assert.ok(folder, "expected grouped folder archive item"); + assert.equal(folder?.threadTitle, "Boss"); + assert.equal(folder?.folderLabel, "2 个线程 · 最近:最新线程"); + assert.equal(folder?.preview, "最近消息:最新线程"); + assert.equal(folder?.lastMessagePreview, "最近消息:最新线程"); + assert.equal(folder?.latestReplyAt, "2026-04-04T12:00:00+08:00"); + assert.equal(folder?.latestReplyLabel, formatTimestampLabel("2026-04-04T12:00:00+08:00")); + assert.equal(folder?.contextBudgetIndicator.level, "critical"); + assert.equal(folder?.contextBudgetIndicator.percent, 87); + assert.equal(folder?.mustFinishBeforeCompaction, true); + assert.equal(folder?.contextBudgetSourceNodeId, "node-urgent"); + assert.equal(folder?.contextBudgetUpdatedAt, "2026-04-04T11:05:00+08:00"); +}); + +test("conversation home upgrades and downgrades a folder archive as thread count crosses 1 and 2", 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", + ), + ); + + let items = getConversationHomeItems(state); + let direct = items.find((item) => item.projectId === "boss-thread-1"); + + assert.ok(direct, "expected a single thread to remain direct"); + assert.equal(direct?.conversationType, "single_device"); + + state.projects.push( + buildImportedThreadProject( + "mac-studio", + "boss-thread-2", + "Boss", + "boss", + "发布回滚", + "thread-2", + "2026-04-04T11:00:00+08:00", + ), + ); + + items = getConversationHomeItems(state); + const 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?.threadCount, 2); + assert.equal(items.some((item) => item.projectId === "boss-thread-1"), false); + assert.equal(items.some((item) => item.projectId === "boss-thread-2"), false); + + state.projects = state.projects.filter((project) => project.id !== "boss-thread-2"); + + items = getConversationHomeItems(state); + direct = items.find((item) => item.projectId === "boss-thread-1"); + + assert.ok(direct, "expected a single remaining thread to downgrade back to direct"); + assert.equal(direct?.conversationType, "single_device"); +}); + test("conversation home groups multiple imported threads by folder while keeping single-thread projects direct", async () => { await setup(); const state = await readState();