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

@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { getConversationFolderView } from "@/lib/boss-projections";
import { readState } from "@/lib/boss-data";
export async function GET(
request: NextRequest,
context: { params: Promise<{ folderKey: string }> },
) {
const session = await requireRequestSession(request);
if (!session) {
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
const { folderKey } = await context.params;
const state = await readState();
const folder = getConversationFolderView(state, decodeURIComponent(folderKey));
if (!folder) {
return NextResponse.json({ ok: false, message: "FOLDER_NOT_FOUND" }, { status: 404 });
}
return NextResponse.json({ ok: true, folder });
}

View File

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { getConversationHomeItems } from "@/lib/boss-projections";
import { readState } from "@/lib/boss-data";
export async function GET(request: NextRequest) {
const session = await requireRequestSession(request);
if (!session) {
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
const state = await readState();
return NextResponse.json({
ok: true,
conversations: getConversationHomeItems(state),
});
}

View File

@@ -0,0 +1,46 @@
import {
AppShell,
ConversationList,
PageNav,
StatusBar,
} from "@/components/app-ui";
import { requirePageSession } from "@/lib/boss-auth";
import { getConversationFolderView } from "@/lib/boss-projections";
import { readState } from "@/lib/boss-data";
export const dynamic = "force-dynamic";
export default async function ConversationFolderPage({
params,
}: {
params: Promise<{ folderKey: string }>;
}) {
await requirePageSession();
const { folderKey } = await params;
const state = await readState();
const folder = getConversationFolderView(state, decodeURIComponent(folderKey));
return (
<AppShell bottomNav={false}>
<StatusBar />
<PageNav title={folder?.folderLabel ?? "项目线程"} backHref="/conversations" />
<div className="space-y-3 px-[18px] pb-6">
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="text-[16px] font-semibold text-[#111111]">
{folder?.folderLabel ?? "未命名项目"}
</div>
<div className="mt-2 text-[13px] leading-6 text-[#57606A]">
{folder
? `${folder.deviceName ?? "当前设备"} · ${folder.threadCount} 个线程`
: "当前项目下没有可显示的线程。"}
</div>
</div>
</div>
{folder ? (
<ConversationList conversations={folder.threads} />
) : (
<div className="px-[18px] pb-6 text-[13px] text-[#8C8C8C]">线</div>
)}
</AppShell>
);
}

View File

@@ -7,7 +7,7 @@ import {
StatusBar,
} from "@/components/app-ui";
import { requirePageSession } from "@/lib/boss-auth";
import { getConversationItems } from "@/lib/boss-projections";
import { getConversationHomeItems } from "@/lib/boss-projections";
import { readState } from "@/lib/boss-data";
export const dynamic = "force-dynamic";
@@ -15,7 +15,7 @@ export const dynamic = "force-dynamic";
export default async function ConversationsPage() {
await requirePageSession();
const state = await readState();
const conversations = getConversationItems(state);
const conversations = getConversationHomeItems(state);
return (
<AppShell>

View File

@@ -7,6 +7,7 @@ import {
HeaderTitle,
StatusBar,
} from "@/components/app-ui";
import { DeviceImportDraftManager } from "@/components/device-import-draft-manager";
import { requirePageSession } from "@/lib/boss-auth";
import { getDeviceWorkspaceView } from "@/lib/boss-projections";
import { readState } from "@/lib/boss-data";
@@ -55,6 +56,12 @@ export default async function DevicesPage({
relatedThreads={workspace.relatedThreads}
activeEnrollment={workspace.activeEnrollment}
/>
<div className="mt-3">
<DeviceImportDraftManager
deviceId={workspace.selectedDevice.id}
deviceName={workspace.selectedDevice.name}
/>
</div>
</div>
) : null}
</AppShell>

View File

@@ -5,6 +5,7 @@ import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import clsx from "clsx";
import { sendAppLog } from "@/components/app-runtime";
import { DeviceImportDraftManager } from "@/components/device-import-draft-manager";
import {
clearNativeSessionSnapshot,
currentAppLocation,
@@ -353,6 +354,10 @@ 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]" />;
}
async function runAction(action: "toggle_pin" | "mark_read") {
setLoading(action);
await fetch(conversationActionsPath(conversation.projectId), {
@@ -405,7 +410,14 @@ export function ConversationList({
<div className="mb-2 flex justify-end">
<ConversationActionButtons conversation={conversation} />
</div>
<Link href={`/conversations/${conversation.projectId}`} className="flex items-start gap-3">
<Link
href={
conversation.conversationType === "folder_archive" && conversation.folderKey
? `/conversations/folders/${encodeURIComponent(conversation.folderKey)}`
: `/conversations/${conversation.projectId}`
}
className="flex items-start gap-3"
>
<AvatarStack
primary={conversation.avatar.primary}
secondary={conversation.avatar.secondary}
@@ -416,20 +428,28 @@ export function ConversationList({
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="truncate text-[17px] font-medium text-[#111111]">
{conversation.projectTitle}
{conversation.conversationType === "folder_archive"
? conversation.threadTitle
: conversation.projectTitle}
</div>
<span
className={clsx(
"rounded-full px-2 py-0.5 text-[10px] font-semibold",
riskBadgeColor(conversation.riskLevel),
)}
>
{conversation.riskLevel === "high"
? "高风险"
: conversation.riskLevel === "medium"
? "关注"
: "稳定"}
</span>
{conversation.conversationType === "folder_archive" ? (
<span className="rounded-full bg-[#F4F5F7] px-2 py-0.5 text-[10px] font-semibold text-[#57606A]">
{conversation.threadCount ?? 0} 线
</span>
) : (
<span
className={clsx(
"rounded-full px-2 py-0.5 text-[10px] font-semibold",
riskBadgeColor(conversation.riskLevel),
)}
>
{conversation.riskLevel === "high"
? "高风险"
: conversation.riskLevel === "medium"
? "关注"
: "稳定"}
</span>
)}
{conversation.unreadCount > 0 ? (
<span className="rounded-full bg-[#FF4D4F] px-2 py-0.5 text-[10px] font-semibold text-white">
{conversation.unreadCount}
@@ -437,7 +457,9 @@ export function ConversationList({
) : null}
</div>
<div className="mt-1 text-[13px] text-[#8C8C8C]">
{conversation.deviceNamesPreview.join(" / ")}
{conversation.conversationType === "folder_archive"
? conversation.folderLabel
: conversation.deviceNamesPreview.join(" / ")}
</div>
<div className="mt-1 truncate text-[14px] text-[#57606A]">
{conversation.preview}
@@ -1450,6 +1472,9 @@ export function DeviceEnrollmentBuilder() {
{configSnippet}
</pre>
) : null}
{result.device?.id ? (
<DeviceImportDraftManager deviceId={result.device.id} deviceName={result.device.name} />
) : null}
</div>
);
}

View File

@@ -0,0 +1,243 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import type { DeviceImportDraft, DeviceImportResolution } from "@/lib/boss-data";
type ImportDraftResponse = {
ok: boolean;
draft?: DeviceImportDraft | null;
resolution?: DeviceImportResolution | null;
message?: string;
};
function groupCandidates(draft: DeviceImportDraft | null) {
const groups = new Map<
string,
Array<DeviceImportDraft["candidates"][number]>
>();
for (const candidate of draft?.candidates ?? []) {
const key = candidate.codexFolderRef?.trim() || candidate.folderRef?.trim() || candidate.folderName;
const bucket = groups.get(key) ?? [];
bucket.push(candidate);
groups.set(key, bucket);
}
return [...groups.entries()].map(([key, items]) => ({
key,
folderName: items[0]?.folderName ?? "未命名项目",
items: [...items].sort((a, b) => b.lastActiveAt.localeCompare(a.lastActiveAt)),
}));
}
export function DeviceImportDraftManager({
deviceId,
deviceName,
}: {
deviceId: string;
deviceName?: string;
}) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
const [draft, setDraft] = useState<DeviceImportDraft | null>(null);
const [resolution, setResolution] = useState<DeviceImportResolution | null>(null);
const [selectedCandidateIds, setSelectedCandidateIds] = useState<string[]>([]);
const loadDraft = useCallback(async () => {
setLoading(true);
const response = await fetch(`/api/v1/devices/${deviceId}/import-draft`, { cache: "no-store" });
const data = (await response.json()) as ImportDraftResponse;
setLoading(false);
setDraft(data.draft ?? null);
setResolution(data.resolution ?? null);
setSelectedCandidateIds(data.draft?.selectedCandidateIds ?? []);
setMessage(data.ok ? "" : data.message ?? "导入草稿加载失败");
}, [deviceId]);
useEffect(() => {
const timer = window.setTimeout(() => {
void loadDraft();
}, 0);
return () => window.clearTimeout(timer);
}, [loadDraft]);
const groups = useMemo(() => groupCandidates(draft), [draft]);
function toggle(candidateId: string) {
setSelectedCandidateIds((current) =>
current.includes(candidateId)
? current.filter((item) => item !== candidateId)
: [...current, candidateId],
);
}
async function reviewSelection() {
setLoading(true);
const selectResponse = await fetch(`/api/v1/devices/${deviceId}/import-draft/select`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ selectedCandidateIds }),
});
const selectResult = (await selectResponse.json()) as { ok: boolean; message?: string; draft?: DeviceImportDraft };
if (!selectResult.ok) {
setLoading(false);
setMessage(selectResult.message ?? "勾选保存失败");
return;
}
const reviewResponse = await fetch(`/api/v1/devices/${deviceId}/import-draft/review`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const reviewResult = (await reviewResponse.json()) as {
ok: boolean;
message?: string;
draft?: DeviceImportDraft;
resolution?: DeviceImportResolution;
};
setLoading(false);
if (!reviewResult.ok) {
setMessage(reviewResult.message ?? "导入建议生成失败");
return;
}
setDraft(reviewResult.draft ?? selectResult.draft ?? null);
setResolution(reviewResult.resolution ?? null);
setMessage("已生成导入建议。");
}
async function applyResolution() {
setLoading(true);
const response = await fetch(`/api/v1/devices/${deviceId}/import-draft/apply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const result = (await response.json()) as {
ok: boolean;
message?: string;
draft?: DeviceImportDraft;
resolution?: DeviceImportResolution;
};
setLoading(false);
if (!result.ok) {
setMessage(result.message ?? "导入应用失败");
return;
}
setDraft(result.draft ?? draft);
setResolution(result.resolution ?? resolution);
setMessage("已把选中的项目线程导入到会话首页。");
router.refresh();
}
return (
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="flex items-center justify-between">
<div>
<div className="text-[16px] font-semibold text-[#111111]"> Codex </div>
<div className="mt-1 text-[12px] text-[#8C8C8C]">
{deviceName ?? deviceId} heartbeat 线
</div>
</div>
<button
type="button"
onClick={() => void loadDraft()}
disabled={loading}
className="rounded-full border border-[#D9D9D9] px-3 py-1 text-[12px] text-[#57606A]"
>
{loading ? "刷新中" : "刷新"}
</button>
</div>
{draft ? (
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
线{draft.candidates.length}
<br />
{draft.status}
<br />
{selectedCandidateIds.length}
</div>
) : (
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
稿线
</div>
)}
{groups.map((group) => (
<div key={group.key} className="rounded-2xl border border-[#EAECEF] bg-[#FCFCFD] px-3 py-3">
<div className="text-[14px] font-semibold text-[#111111]">{group.folderName}</div>
<div className="mt-1 text-[12px] text-[#8C8C8C]">{group.items.length} 线</div>
<div className="mt-3 space-y-2">
{group.items.map((candidate) => {
const selected = selectedCandidateIds.includes(candidate.candidateId);
return (
<label
key={candidate.candidateId}
className="flex cursor-pointer items-start gap-3 rounded-2xl border border-[#E5E5EA] bg-white px-3 py-3"
>
<input
type="checkbox"
className="mt-1 h-4 w-4 accent-[#07C160]"
checked={selected}
onChange={() => toggle(candidate.candidateId)}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-[14px] font-medium text-[#111111]">
{candidate.threadDisplayName}
</div>
<div className="mt-1 text-[12px] text-[#8C8C8C]">
{candidate.lastActiveAt}
</div>
</div>
{candidate.suggestedImport ? (
<span className="rounded-full bg-[#EAF7F0] px-2 py-0.5 text-[10px] font-semibold text-[#215B39]">
</span>
) : null}
</label>
);
})}
</div>
</div>
))}
{resolution ? (
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
<div className="font-semibold text-[#111111]">{resolution.summary}</div>
<div className="mt-2 space-y-1">
{resolution.items.map((item) => (
<div key={item.candidateId}>
{item.threadDisplayName} · {item.folderName} · {item.action}
</div>
))}
</div>
</div>
) : null}
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => void reviewSelection()}
disabled={loading || selectedCandidateIds.length === 0}
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:bg-[#B7E6C9]"
>
</button>
<button
type="button"
onClick={() => void applyResolution()}
disabled={loading || !resolution || draft?.status === "applied"}
className="rounded-full border border-[#D9D9D9] px-4 py-2 text-[13px] font-semibold text-[#57606A] disabled:text-[#B8B8B8]"
>
{draft?.status === "applied" ? "已导入" : "应用导入"}
</button>
</div>
{message ? (
<div className="rounded-2xl bg-[#EAF7F0] px-4 py-3 text-[12px] leading-6 text-[#215B39]">
{message}
</div>
) : null}
</div>
);
}

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;