mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 01:45:52 +02:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -143,8 +143,7 @@
|
||||
"waiting": "Waiting for reply",
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin",
|
||||
"pinned": "Pinned",
|
||||
"archived": "Archived"
|
||||
"pinned": "Pinned"
|
||||
},
|
||||
"header": {
|
||||
"view_profile": "View agent profile",
|
||||
|
||||
@@ -140,8 +140,7 @@
|
||||
"waiting": "返信待ち",
|
||||
"pin": "ピン留め",
|
||||
"unpin": "ピン留めを解除",
|
||||
"pinned": "ピン留め済み",
|
||||
"archived": "アーカイブ済み"
|
||||
"pinned": "ピン留め済み"
|
||||
},
|
||||
"header": {
|
||||
"view_profile": "Agent のプロフィール",
|
||||
|
||||
@@ -140,8 +140,7 @@
|
||||
"waiting": "답장 대기 중",
|
||||
"pin": "고정",
|
||||
"unpin": "고정 해제",
|
||||
"pinned": "고정됨",
|
||||
"archived": "보관됨"
|
||||
"pinned": "고정됨"
|
||||
},
|
||||
"header": {
|
||||
"view_profile": "Agent 프로필 보기",
|
||||
|
||||
@@ -140,8 +140,7 @@
|
||||
"waiting": "等待回复",
|
||||
"pin": "置顶",
|
||||
"unpin": "取消置顶",
|
||||
"pinned": "已置顶",
|
||||
"archived": "已归档"
|
||||
"pinned": "已置顶"
|
||||
},
|
||||
"header": {
|
||||
"view_profile": "查看 Agent 资料",
|
||||
|
||||
Reference in New Issue
Block a user