feat: align web conversation folders with drawer design
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user