refactor(chat): drop chat-list archive marker, keep conversation read-only (MUL-4265)

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Lambda
2026-07-08 19:21:13 +08:00
parent 754097ad84
commit d252a77f40
6 changed files with 7 additions and 197 deletions

View File

@@ -1,166 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@multica/core/i18n/react";
import { chatKeys } from "@multica/core/chat/queries";
import type { Agent, ChatSession } from "@multica/core/types";
import enChat from "../../locales/en/chat.json";
// ActorAvatar fetches agent presence/photo — stub it to a marker span so the
// list renders without a QueryClient round-trip, and so we can assert the
// archived dimming class lands on the avatar.
vi.mock("../../common/actor-avatar", () => ({
ActorAvatar: ({ actorId, className }: { actorId: string; className?: string }) => (
<span data-testid={`avatar-${actorId}`} className={className} />
),
}));
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
vi.mock("@multica/core/agents", () => ({
useWorkspacePresenceMap: () => ({ byAgent: new Map() }),
}));
vi.mock("@multica/core/chat", () => {
const state = { setActiveSession: vi.fn() };
return {
useChatStore: Object.assign(
(selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state),
{ getState: () => state },
),
};
});
vi.mock("@multica/core/chat/mutations", () => ({
useDeleteChatSession: () => ({ mutate: vi.fn(), isPending: false }),
useSetChatSessionPinned: () => ({ mutate: vi.fn() }),
}));
import { ChatThreadList } from "./chat-thread-list";
const TEST_RESOURCES = { en: { chat: enChat } };
function makeAgent(overrides: Partial<Agent> & Pick<Agent, "id" | "name">): Agent {
return {
workspace_id: "ws-1",
runtime_id: "runtime-1",
owner_id: "user-1",
description: "",
instructions: "",
avatar_url: null,
runtime_mode: "local",
runtime_config: {},
custom_args: [],
visibility: "workspace",
permission_mode: "public_to",
invocation_targets: [{ target_type: "workspace", target_id: null }],
status: "idle",
max_concurrent_tasks: 1,
model: "sonnet",
skills: [],
created_at: new Date(0).toISOString(),
updated_at: new Date(0).toISOString(),
archived_at: null,
archived_by: null,
...overrides,
} as Agent;
}
function makeSession(overrides: Partial<ChatSession> & Pick<ChatSession, "id" | "agent_id">): ChatSession {
return {
workspace_id: "ws-1",
title: "A conversation",
status: "active",
pinned: false,
unread_count: 0,
has_unread: false,
last_message: {
content: "hello there",
role: "assistant",
created_at: "2026-07-08T00:00:00Z",
failure_reason: null,
},
created_at: "2026-07-08T00:00:00Z",
updated_at: "2026-07-08T00:00:00Z",
...overrides,
} as ChatSession;
}
function renderList(sessions: ChatSession[], agents: Agent[]) {
const qc = new QueryClient();
qc.setQueryData(chatKeys.pendingTasks("ws-1"), { tasks: [] });
return render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<QueryClientProvider client={qc}>
<ChatThreadList
sessions={sessions}
agents={agents}
activeSessionId={null}
onSelectSession={vi.fn()}
/>
</QueryClientProvider>
</I18nProvider>,
);
}
describe("ChatThreadList archived-agent handling (MUL-4265)", () => {
it("marks a session whose agent is archived and dims its avatar", () => {
const archived = makeAgent({
id: "agent-archived",
name: "Retired Bot",
archived_at: "2026-07-01T00:00:00Z",
});
renderList(
[makeSession({ id: "s-1", agent_id: "agent-archived", title: "Old chat" })],
[archived],
);
// The "Archived" tag renders on the row.
expect(screen.getByText(enChat.list.archived)).toBeInTheDocument();
// The avatar is desaturated/dimmed.
expect(screen.getByTestId("avatar-agent-archived").className).toContain("grayscale");
});
it("does not mark a session whose agent is active", () => {
const active = makeAgent({ id: "agent-active", name: "Active Bot" });
renderList(
[makeSession({ id: "s-2", agent_id: "agent-active", title: "Live chat" })],
[active],
);
expect(screen.queryByText(enChat.list.archived)).not.toBeInTheDocument();
expect(screen.getByTestId("avatar-agent-active").className).not.toContain("grayscale");
});
it("suppresses the live 'typing' indicator for an archived agent even with a pending task", () => {
const qc = new QueryClient();
// A stray pending task on the archived agent's session must NOT render as
// 'typing…' — a retired agent can never be running.
qc.setQueryData(chatKeys.pendingTasks("ws-1"), {
tasks: [{ task_id: "t-1", chat_session_id: "s-3", status: "running" }],
});
const archived = makeAgent({
id: "agent-archived",
name: "Retired Bot",
archived_at: "2026-07-01T00:00:00Z",
});
render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<QueryClientProvider client={qc}>
<ChatThreadList
sessions={[makeSession({ id: "s-3", agent_id: "agent-archived" })]}
agents={[archived]}
activeSessionId={null}
onSelectSession={vi.fn()}
/>
</QueryClientProvider>
</I18nProvider>,
);
expect(screen.queryByText(enChat.list.typing)).not.toBeInTheDocument();
// Falls back to the last-message preview instead.
expect(screen.getByText(/hello there/)).toBeInTheDocument();
});
});

View File

@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Archive, Clock, Loader2, Pin, PinOff, Square, Trash2 } from "lucide-react";
import { Clock, Loader2, Pin, PinOff, Square, Trash2 } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspacePresenceMap } from "@multica/core/agents";
@@ -145,12 +145,8 @@ export function ChatThreadList({
const renderRow = (session: ChatSession) => {
const isCurrent = session.id === activeSessionId;
const agent = agentById.get(session.agent_id) ?? null;
// A retired agent can't take new work — the conversation is read-only.
// We keep the row (history stays reachable) but mark it and suppress the
// live "typing…/waiting" indicators, which can never apply here.
const agentArchived = !!agent?.archived_at;
const pendingTask = pendingTaskBySessionId.get(session.id);
const isRunning = !!pendingTask && !agentArchived;
const isRunning = !!pendingTask;
// Only "offline" (definitively long-offline) downgrades typing → waiting.
// Unknown/loading presence keeps the optimistic "typing…" so we never
// suppress it just because presence data hasn't landed yet.
@@ -222,17 +218,7 @@ export function ChatThreadList({
{/* Thin ring keeps photo + fallback avatars reading as the same circle
(the fallback's faint bg otherwise looks smaller). */}
{agent ? (
<ActorAvatar
actorType="agent"
actorId={agent.id}
size={36}
enableHoverCard
className={cn(
"ring-1 ring-inset ring-border",
// Retired agent: desaturate + dim so the row reads as inactive.
agentArchived && "opacity-50 grayscale",
)}
/>
<ActorAvatar actorType="agent" actorId={agent.id} size={36} enableHoverCard className="ring-1 ring-inset ring-border" />
) : (
<span className="size-9 shrink-0" />
)}
@@ -249,12 +235,6 @@ export function ChatThreadList({
<span className={cn("min-w-0 flex-1 truncate text-sm", unread > 0 ? "font-semibold text-foreground" : "font-medium")}>
{titleText}
</span>
{agentArchived && (
<span className="inline-flex shrink-0 items-center gap-0.5 rounded-sm bg-muted px-1 py-px text-[10px] font-medium text-muted-foreground">
<Archive className="size-2.5" />
{t(($) => $.list.archived)}
</span>
)}
<span className="ml-auto shrink-0 text-[11px] text-muted-foreground">{timeText}</span>
</div>

View File

@@ -143,8 +143,7 @@
"waiting": "Waiting for reply",
"pin": "Pin",
"unpin": "Unpin",
"pinned": "Pinned",
"archived": "Archived"
"pinned": "Pinned"
},
"header": {
"view_profile": "View agent profile",

View File

@@ -140,8 +140,7 @@
"waiting": "返信待ち",
"pin": "ピン留め",
"unpin": "ピン留めを解除",
"pinned": "ピン留め済み",
"archived": "アーカイブ済み"
"pinned": "ピン留め済み"
},
"header": {
"view_profile": "Agent のプロフィール",

View File

@@ -140,8 +140,7 @@
"waiting": "답장 대기 중",
"pin": "고정",
"unpin": "고정 해제",
"pinned": "고정됨",
"archived": "보관됨"
"pinned": "고정됨"
},
"header": {
"view_profile": "Agent 프로필 보기",

View File

@@ -140,8 +140,7 @@
"waiting": "等待回复",
"pin": "置顶",
"unpin": "取消置顶",
"pinned": "已置顶",
"archived": "已归档"
"pinned": "已置顶"
},
"header": {
"view_profile": "查看 Agent 资料",