feat: group imported threads into project archives

This commit is contained in:
kris
2026-03-30 13:50:26 +08:00
parent 98dd0e3cd5
commit 03ac40f427
23 changed files with 1207 additions and 83 deletions

View File

@@ -33,11 +33,13 @@ export interface ContextIndicator {
export interface ConversationItem {
conversationId: string;
conversationType: "master_agent" | "single_device" | "group";
conversationType: "master_agent" | "single_device" | "group" | "folder_archive";
projectId: string;
projectTitle: string;
threadTitle: string;
folderLabel: string;
folderKey?: string;
threadCount?: number;
preview: string;
lastMessagePreview: string;
activityIconCount: number;
@@ -184,6 +186,14 @@ function projectType(project: Project): ConversationItem["conversationType"] {
return project.isGroup ? "group" : "single_device";
}
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();
if (!deviceId || !folderRef) return undefined;
return `${deviceId}:${folderRef}`;
}
function isTopPinnedConversation(project: Project) {
return Boolean(project.pinned || project.systemPinned || project.id === "audit-collab");
}
@@ -306,63 +316,64 @@ function threadViewsForProject(state: BossState, projectId: string) {
}));
}
export function getConversationItems(state: BossState): ConversationItem[] {
const conversations = state.projects.map((project) => {
const devices = state.devices.filter((device) => project.deviceIds.includes(device.id));
const threadViews = threadViewsForProject(state, project.id);
const topThread = threadViews[0]?.snapshot;
const threadTitle = project.threadMeta?.threadDisplayName ?? project.name;
const folderLabel = project.threadMeta?.folderName ?? "";
const activityIconCount = project.threadMeta?.activityIconCount ?? 1;
const topPinnedLabel = isTopPinnedConversation(project) ? "置顶" : undefined;
const groupMembers = project.isGroup
? project.groupMembers.map((member) => ({
threadId: member.threadId,
avatar: getGroupMemberAvatar(
member,
state.devices.find((device) => device.id === member.deviceId),
),
title: member.threadDisplayName,
}))
: undefined;
function buildConversationItem(state: BossState, project: Project): ConversationItem {
const devices = state.devices.filter((device) => project.deviceIds.includes(device.id));
const threadViews = threadViewsForProject(state, project.id);
const topThread = threadViews[0]?.snapshot;
const threadTitle = project.threadMeta?.threadDisplayName ?? project.name;
const folderLabel = project.threadMeta?.folderName ?? "";
const activityIconCount = project.threadMeta?.activityIconCount ?? 1;
const topPinnedLabel = isTopPinnedConversation(project) ? "置顶" : undefined;
const groupMembers = project.isGroup
? project.groupMembers.map((member) => ({
threadId: member.threadId,
avatar: getGroupMemberAvatar(
member,
state.devices.find((device) => device.id === member.deviceId),
),
title: member.threadDisplayName,
}))
: undefined;
return {
conversationId: `conv-${project.id}`,
conversationType: projectType(project),
projectId: project.id,
projectTitle: project.name,
threadTitle,
folderLabel,
preview: project.preview,
lastMessagePreview: project.preview,
activityIconCount,
topPinnedLabel,
manualPinned: Boolean(project.pinned && !project.systemPinned),
latestReplyAt: project.lastMessageAt,
latestReplyLabel: formatTimestampLabel(project.lastMessageAt),
unreadCount: project.unreadCount,
riskLevel: project.riskLevel,
activeDeviceCount: devices.length,
deviceNamesPreview: devices.map((device) => device.name),
avatar: {
primary: devices[0]?.avatar ?? "A",
secondary: project.isGroup ? devices[1]?.avatar : undefined,
overflowCount: Math.max(0, devices.length - 2) || undefined,
},
groupMembers,
contextBudgetIndicator: {
visible: !project.isGroup && Boolean(topThread),
style: "ring_percent",
percent: !project.isGroup ? topThread?.contextBudgetRemainingPct : undefined,
level: !project.isGroup ? topThread?.contextBudgetLevel : undefined,
},
contextBudgetSourceNodeId: !project.isGroup ? topThread?.nodeId : undefined,
contextBudgetUpdatedAt: !project.isGroup ? topThread?.capturedAt : undefined,
mustFinishBeforeCompaction: Boolean(topThread?.mustFinishBeforeCompaction),
} satisfies ConversationItem;
});
return {
conversationId: `conv-${project.id}`,
conversationType: projectType(project),
projectId: project.id,
projectTitle: project.name,
threadTitle,
folderLabel,
folderKey: buildFolderKey(project),
preview: project.preview,
lastMessagePreview: project.preview,
activityIconCount,
topPinnedLabel,
manualPinned: Boolean(project.pinned && !project.systemPinned),
latestReplyAt: project.lastMessageAt,
latestReplyLabel: formatTimestampLabel(project.lastMessageAt),
unreadCount: project.unreadCount,
riskLevel: project.riskLevel,
activeDeviceCount: devices.length,
deviceNamesPreview: devices.map((device) => device.name),
avatar: {
primary: devices[0]?.avatar ?? "A",
secondary: project.isGroup ? devices[1]?.avatar : undefined,
overflowCount: Math.max(0, devices.length - 2) || undefined,
},
groupMembers,
contextBudgetIndicator: {
visible: !project.isGroup && Boolean(topThread),
style: "ring_percent",
percent: !project.isGroup ? topThread?.contextBudgetRemainingPct : undefined,
level: !project.isGroup ? topThread?.contextBudgetLevel : undefined,
},
contextBudgetSourceNodeId: !project.isGroup ? topThread?.nodeId : undefined,
contextBudgetUpdatedAt: !project.isGroup ? topThread?.capturedAt : undefined,
mustFinishBeforeCompaction: Boolean(topThread?.mustFinishBeforeCompaction),
} satisfies ConversationItem;
}
return conversations.sort((a, b) => {
function sortConversationItems(items: ConversationItem[]) {
return items.sort((a, b) => {
if (a.projectId === "master-agent") return -1;
if (b.projectId === "master-agent") return 1;
const aPinned = Boolean(a.topPinnedLabel);
@@ -372,6 +383,128 @@ export function getConversationItems(state: BossState): ConversationItem[] {
});
}
export function getConversationItems(state: BossState): ConversationItem[] {
const conversations = state.projects.map((project) => buildConversationItem(state, project));
return sortConversationItems(conversations);
}
export interface ConversationFolderView {
folderKey: string;
folderLabel: string;
deviceId?: string;
deviceName?: string;
threadCount: number;
threads: ConversationItem[];
}
export function getConversationHomeItems(state: BossState): ConversationItem[] {
const flatItems = getConversationItems(state);
const projectMap = new Map(state.projects.map((project) => [project.id, project]));
const grouped = new Map<string, ConversationItem[]>();
const passthrough: ConversationItem[] = [];
for (const item of flatItems) {
const project = projectMap.get(item.projectId);
if (!project || item.conversationType !== "single_device") {
passthrough.push(item);
continue;
}
const folderKey = buildFolderKey(project);
if (!folderKey) {
passthrough.push(item);
continue;
}
const bucket = grouped.get(folderKey) ?? [];
bucket.push(item);
grouped.set(folderKey, bucket);
}
for (const [folderKey, items] of grouped) {
if (items.length <= 1) {
passthrough.push(items[0]);
continue;
}
const latestItem = [...items].sort((a, b) => b.latestReplyAt.localeCompare(a.latestReplyAt))[0];
const project = projectMap.get(latestItem.projectId);
const device = project?.deviceIds[0]
? state.devices.find((entry) => entry.id === project.deviceIds[0])
: undefined;
passthrough.push({
conversationId: `folder-${folderKey}`,
conversationType: "folder_archive",
projectId: folderKey,
projectTitle:
project?.threadMeta.folderName ??
(latestItem.folderLabel || latestItem.projectTitle),
threadTitle:
project?.threadMeta.folderName ??
(latestItem.folderLabel || latestItem.threadTitle),
folderLabel: `${device?.name ?? latestItem.deviceNamesPreview[0] ?? "设备"} · ${items.length} 个线程`,
folderKey,
threadCount: items.length,
preview:
latestItem.preview || `包含 ${items.length} 个线程,最近活跃:《${latestItem.threadTitle}`,
lastMessagePreview:
latestItem.lastMessagePreview ||
latestItem.preview ||
`包含 ${items.length} 个线程,最近活跃:《${latestItem.threadTitle}`,
activityIconCount: Math.max(
1,
Math.min(
4,
items.reduce((sum, entry) => sum + Math.max(1, entry.activityIconCount), 0),
),
),
manualPinned: false,
topPinnedLabel: undefined,
latestReplyAt: latestItem.latestReplyAt,
latestReplyLabel: latestItem.latestReplyLabel,
unreadCount: items.reduce((sum, entry) => sum + entry.unreadCount, 0),
riskLevel: items.some((entry) => entry.riskLevel === "high")
? "high"
: items.some((entry) => entry.riskLevel === "medium")
? "medium"
: "low",
activeDeviceCount: 1,
deviceNamesPreview: device ? [device.name] : latestItem.deviceNamesPreview,
avatar: {
primary: device?.avatar ?? latestItem.avatar.primary,
},
contextBudgetIndicator: {
visible: false,
style: "ring_percent",
},
mustFinishBeforeCompaction: false,
});
}
return sortConversationItems(passthrough);
}
export function getConversationFolderView(
state: BossState,
folderKey: string,
): ConversationFolderView | null {
const flatItems = getConversationItems(state).filter(
(item) => item.conversationType === "single_device" && item.folderKey === folderKey,
);
if (flatItems.length === 0) {
return null;
}
const project = state.projects.find((entry) => buildFolderKey(entry) === folderKey);
const deviceId = project?.deviceIds[0];
const device = deviceId ? state.devices.find((entry) => entry.id === deviceId) : undefined;
return {
folderKey,
folderLabel: project?.threadMeta.folderName ?? flatItems[0].folderLabel,
deviceId,
deviceName: device?.name,
threadCount: flatItems.length,
threads: sortConversationItems(flatItems),
};
}
export function getProjectDetailView(state: BossState, projectId: string): ProjectDetailView | null {
const project = state.projects.find((item) => item.id === projectId);
if (!project) return null;