feat: align web conversation folders with drawer design

This commit is contained in:
kris
2026-04-05 13:26:50 +08:00
parent a17c702edf
commit 447e9e0b62
2 changed files with 125 additions and 88 deletions

View File

@@ -351,6 +351,18 @@ function riskBadgeColor(level: ConversationItem["riskLevel"]) {
}
}
export function getConversationListItemPresentation(conversation: ConversationItem) {
const isFolderArchive = conversation.conversationType === "folder_archive";
return {
href:
isFolderArchive && conversation.folderKey
? `/conversations/folders/${encodeURIComponent(conversation.folderKey)}`
: `/conversations/${conversation.projectId}`,
title: isFolderArchive ? conversation.threadTitle : conversation.projectTitle,
subtitle: isFolderArchive ? conversation.folderLabel : conversation.deviceNamesPreview.join(" / "),
};
}
function conversationActionsPath(projectId: string) {
return `/api/v1/conversations/${projectId}/actions`;
}
@@ -411,100 +423,86 @@ export function ConversationList({
}) {
return (
<div className="flex flex-col gap-1 px-[18px] pb-5">
{conversations.map((conversation) => (
<div
key={conversation.conversationId}
className="rounded-2xl px-1 py-3 transition hover:bg-white/70"
>
<div className="mb-2 flex justify-end">
<ConversationActionButtons conversation={conversation} />
</div>
<Link
href={
conversation.conversationType === "folder_archive" && conversation.folderKey
? `/conversations/folders/${encodeURIComponent(conversation.folderKey)}`
: `/conversations/${conversation.projectId}`
}
className="flex items-start gap-3"
{conversations.map((conversation) => {
const presentation = getConversationListItemPresentation(conversation);
const isFolderArchive = conversation.conversationType === "folder_archive";
return (
<div
key={conversation.conversationId}
className="rounded-2xl px-1 py-3 transition hover:bg-white/70"
>
<AvatarStack
primary={conversation.avatar.primary}
secondary={conversation.avatar.secondary}
overflowCount={conversation.avatar.overflowCount}
/>
<div className="min-w-0 flex-1 border-b border-[#EFEFF4] pb-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="truncate text-[17px] font-medium text-[#111111]">
{conversation.conversationType === "folder_archive"
? conversation.threadTitle
: conversation.projectTitle}
<div className="mb-2 flex justify-end">
<ConversationActionButtons conversation={conversation} />
</div>
<Link href={presentation.href} className="flex items-start gap-3">
<AvatarStack
primary={conversation.avatar.primary}
secondary={conversation.avatar.secondary}
overflowCount={conversation.avatar.overflowCount}
/>
<div className="min-w-0 flex-1 border-b border-[#EFEFF4] pb-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="truncate text-[17px] font-medium text-[#111111]">{presentation.title}</div>
{isFolderArchive ? (
<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}
</span>
) : null}
</div>
{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}
</span>
) : null}
<div className="mt-1 text-[13px] text-[#8C8C8C]">{presentation.subtitle}</div>
<div className="mt-1 truncate text-[14px] text-[#57606A]">{conversation.preview}</div>
</div>
<div className="mt-1 text-[13px] text-[#8C8C8C]">
{conversation.conversationType === "folder_archive"
? conversation.folderLabel
: conversation.deviceNamesPreview.join(" / ")}
</div>
<div className="mt-1 truncate text-[14px] text-[#57606A]">
{conversation.preview}
</div>
</div>
<div className="flex min-w-[98px] flex-col items-end gap-1">
<div className="min-h-[18px] text-[11px] text-[#07C160]">
{conversation.projectId === "master-agent"
? "置顶"
: conversation.manualPinned
<div className="flex min-w-[98px] flex-col items-end gap-1">
<div className="min-h-[18px] text-[11px] text-[#07C160]">
{conversation.projectId === "master-agent"
? "置顶"
: ""}
</div>
<div className="text-[12px] text-[#8C8C8C]">
{conversation.latestReplyLabel}
</div>
{conversation.contextBudgetIndicator.visible &&
conversation.contextBudgetIndicator.percent !== undefined ? (
<ContextRing
value={conversation.contextBudgetIndicator.percent}
label={`${conversation.contextBudgetIndicator.percent}%`}
level={conversation.contextBudgetIndicator.level}
/>
) : (
<div className="min-h-[16px] text-[11px] text-[#8C8C8C]">
{conversation.activeDeviceCount > 1
? `${conversation.activeDeviceCount} 台协作`
: ""}
: conversation.manualPinned
? "置顶"
: ""}
</div>
)}
<div className="text-[12px] text-[#8C8C8C]">{conversation.latestReplyLabel}</div>
{conversation.contextBudgetIndicator.visible &&
conversation.contextBudgetIndicator.percent !== undefined ? (
<ContextRing
value={conversation.contextBudgetIndicator.percent}
label={`${conversation.contextBudgetIndicator.percent}%`}
level={conversation.contextBudgetIndicator.level}
/>
) : (
<div className="min-h-[16px] text-[11px] text-[#8C8C8C]">
{conversation.activeDeviceCount > 1
? `${conversation.activeDeviceCount} 台协作`
: ""}
</div>
)}
</div>
</div>
</div>
</div>
</Link>
</div>
))}
</Link>
</div>
);
})}
</div>
);
}

View File

@@ -9,6 +9,7 @@ let readState: (typeof import("../src/lib/boss-data"))["readState"];
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"];
async function setup() {
if (runtimeRoot) return;
@@ -16,14 +17,16 @@ async function setup() {
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
const [data, projections] = await Promise.all([
const [data, projections, ui] = await Promise.all([
import("../src/lib/boss-data.ts"),
import("../src/lib/boss-projections.ts"),
import("../src/components/app-ui.tsx"),
]);
readState = data.readState;
getConversationHomeItems = projections.getConversationHomeItems;
getConversationFolderView = projections.getConversationFolderView;
formatTimestampLabel = projections.formatTimestampLabel;
getConversationListItemPresentation = ui.getConversationListItemPresentation;
}
test.after(async () => {
@@ -392,6 +395,42 @@ test("conversation home groups multiple imported threads by folder while keeping
);
});
test("folder archive homepage rows keep the project title, compact subtitle, and folder route", 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",
),
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 presentation = getConversationListItemPresentation(folder!);
assert.equal(presentation.title, "Boss");
assert.equal(presentation.subtitle, "2 个线程 · 最近:发布回滚");
assert.equal(presentation.href, "/conversations/folders/mac-studio%3Aboss");
});
test("conversation items expose context status while keeping idle activity silent", async () => {
await setup();
const state = await readState();