feat: group imported threads into project archives
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user