feat(chat): handle archived agents in Chat V2 list & conversation (MUL-4265)

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Lambda
2026-07-08 19:02:05 +08:00
parent 7b6fa2a14a
commit 754097ad84
10 changed files with 272 additions and 10 deletions

View File

@@ -25,6 +25,7 @@ import { NewChatButton } from "./components/new-chat-button";
import { useChatController } from "./components/use-chat-controller";
import { OfflineBanner } from "./components/offline-banner";
import { NoAgentBanner } from "./components/no-agent-banner";
import { ArchivedAgentBanner } from "./components/archived-agent-banner";
/**
* Chat tab — the first-class two-pane surface (thread list on the left,
@@ -162,6 +163,8 @@ export function ChatPage() {
{c.noAgent ? (
<NoAgentBanner />
) : c.isAgentArchived ? (
<ArchivedAgentBanner agentName={c.activeAgent?.name} />
) : (
<OfflineBanner agentName={c.activeAgent?.name} availability={c.availability} />
)}
@@ -173,8 +176,9 @@ export function ChatPage() {
onUploadFile={c.handleUploadFile}
onStop={c.handleStop}
isRunning={!!c.pendingTaskId}
disabled={c.isSessionArchived}
disabled={c.isSessionArchived || c.isAgentArchived}
noAgent={c.noAgent}
agentArchived={c.isAgentArchived}
agentName={c.activeAgent?.name}
/>
</div>

View File

@@ -0,0 +1,26 @@
"use client";
import { Archive } from "lucide-react";
import { useT } from "../../i18n";
// Sibling of OfflineBanner / NoAgentBanner, occupying the same banner slot
// above the chat input. Shown when the open session's agent has been archived
// (retired): the input above is disabled and this banner explains that the
// conversation is read-only history — the agent can no longer reply.
//
// Layout (`px-5` outer, `mx-auto max-w-4xl` inner) mirrors its siblings so the
// banner's edges line up with the input on every viewport size.
export function ArchivedAgentBanner({ agentName }: { agentName?: string }) {
const { t } = useT("chat");
const name = agentName?.trim() || t(($) => $.offline_banner.fallback_name);
return (
<div className="px-5 mb-1.5">
<div className="mx-auto flex w-full max-w-4xl items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs bg-muted text-muted-foreground ring-1 ring-border">
<Archive className="size-3.5 shrink-0" />
<span className="truncate">
{t(($) => $.archived_agent_banner, { name })}
</span>
</div>
</div>
);
}

View File

@@ -75,6 +75,10 @@ interface ChatInputProps {
* surfaces a distinct placeholder. Kept separate from `disabled` so
* archived-session copy stays untouched. */
noAgent?: boolean;
/** True when `disabled` is because the bound agent was archived (retired),
* as opposed to the session itself being archived — swaps the placeholder
* copy so the read-only reason reads accurately. */
agentArchived?: boolean;
/** Name of the currently selected agent, used in the placeholder. */
agentName?: string;
/** Rendered at the bottom-left of the input bar — typically the agent picker. */
@@ -92,6 +96,7 @@ export function ChatInput({
isRunning,
disabled,
noAgent,
agentArchived,
agentName,
leftAdornment,
contextItems,
@@ -320,7 +325,9 @@ export function ChatInput({
const placeholder = noAgent
? t(($) => $.input.placeholder_no_agent)
: disabled
? t(($) => $.input.placeholder_archived)
? agentArchived
? t(($) => $.input.placeholder_archived_agent)
: t(($) => $.input.placeholder_archived)
: agentName
? t(($) => $.input.placeholder_named, { name: agentName })
: t(($) => $.input.placeholder_default);

View File

@@ -0,0 +1,166 @@
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 { Clock, Loader2, Pin, PinOff, Square, Trash2 } from "lucide-react";
import { Archive, 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,8 +145,12 @@ 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;
const isRunning = !!pendingTask && !agentArchived;
// 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.
@@ -218,7 +222,17 @@ 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="ring-1 ring-inset ring-border" />
<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",
)}
/>
) : (
<span className="size-9 shrink-0" />
)}
@@ -235,6 +249,12 @@ 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

@@ -255,8 +255,23 @@ export function useChatController(opts?: { isActive?: boolean }) {
(a) => !a.archived_at && canAssignAgent(a, user?.id, memberRole),
);
// Resolve selected agent: stored preference → first available
// The agent bound to the OPEN session, resolved from the full agent list
// (archived included, since agentListOptions passes include_archived). An
// archived agent is filtered out of `availableAgents`, so resolving the
// active agent only from that list would make an archived-agent session
// silently render some *other* available agent — wrong avatar/name/presence
// in the header, and a send that targets the wrong agent. Binding to the
// session's real agent keeps the conversation honest; the archived state
// then makes it read-only (see isAgentArchived).
const sessionAgent = currentSession
? agents.find((a) => a.id === currentSession.agent_id) ?? null
: null;
const isAgentArchived = !!sessionAgent?.archived_at;
// Resolve selected agent: open session's agent → stored preference → first
// available. New chats have no session, so they fall through to the picker.
const activeAgent =
sessionAgent ??
availableAgents.find((a) => a.id === selectedAgentId) ??
availableAgents[0] ??
null;
@@ -402,6 +417,16 @@ export function useChatController(opts?: { isActive?: boolean }) {
apiLogger.warn("sendChatMessage skipped: no active agent");
return false;
}
// Read-only conversation: the agent is retired and can no longer pick up
// work, so refuse to enqueue a task that would sit orphaned forever. The
// input is disabled in this state; this is the belt-and-braces guard.
if (isAgentArchived) {
apiLogger.warn("sendChatMessage skipped: agent is archived", {
sessionId: activeSessionId,
agentId: activeAgent.id,
});
return false;
}
const finalContent = content;
const isNewSession = !activeSessionId;
@@ -513,6 +538,7 @@ export function useChatController(opts?: { isActive?: boolean }) {
activeSessionId,
selectedAgentId,
activeAgent,
isAgentArchived,
ensureSession,
cancelChatTask,
qc,
@@ -594,6 +620,7 @@ export function useChatController(opts?: { isActive?: boolean }) {
selectedAgentId,
currentSession,
isSessionArchived,
isAgentArchived,
activeAgent,
noAgent,
availability,

View File

@@ -12,6 +12,7 @@
"input": {
"placeholder_no_agent": "Create an agent to start chatting",
"placeholder_archived": "This session is archived",
"placeholder_archived_agent": "This agent has been archived",
"placeholder_named": "Message {{name}}…",
"placeholder_default": "Start a message…",
"send_tooltip": "Send",
@@ -105,6 +106,7 @@
"plan_next": "Plan what to work on next"
},
"no_agent_banner": "You need an agent to start chatting.",
"archived_agent_banner": "{{name}} has been archived — this conversation is read-only.",
"offline_banner": {
"fallback_name": "the agent",
"unstable": "{{name}}'s connection is unstable — replies may be delayed.",
@@ -141,7 +143,8 @@
"waiting": "Waiting for reply",
"pin": "Pin",
"unpin": "Unpin",
"pinned": "Pinned"
"pinned": "Pinned",
"archived": "Archived"
},
"header": {
"view_profile": "View agent profile",

View File

@@ -11,6 +11,7 @@
"input": {
"placeholder_no_agent": "チャットを始めるにはエージェントを作成してください",
"placeholder_archived": "このセッションはアーカイブされています",
"placeholder_archived_agent": "このエージェントはアーカイブされています",
"placeholder_named": "{{name}} にメッセージ…",
"placeholder_default": "メッセージを入力…",
"send_tooltip": "送信",
@@ -102,6 +103,7 @@
"plan_next": "次に取り組むことを計画して"
},
"no_agent_banner": "チャットを始めるにはエージェントが必要です。",
"archived_agent_banner": "{{name}} はアーカイブされました。この会話は読み取り専用です。",
"offline_banner": {
"fallback_name": "エージェント",
"unstable": "{{name}} の接続が不安定です。返信が遅れる場合があります。",
@@ -138,7 +140,8 @@
"waiting": "返信待ち",
"pin": "ピン留め",
"unpin": "ピン留めを解除",
"pinned": "ピン留め済み"
"pinned": "ピン留め済み",
"archived": "アーカイブ済み"
},
"header": {
"view_profile": "Agent のプロフィール",

View File

@@ -11,6 +11,7 @@
"input": {
"placeholder_no_agent": "채팅을 시작하려면 에이전트를 만드세요",
"placeholder_archived": "보관된 세션입니다",
"placeholder_archived_agent": "보관된 에이전트입니다",
"placeholder_named": "{{name}}에게 메시지 보내기…",
"placeholder_default": "메시지 입력…",
"send_tooltip": "보내기",
@@ -102,6 +103,7 @@
"plan_next": "다음에 할 일을 계획해 줘"
},
"no_agent_banner": "채팅을 시작하려면 에이전트가 필요합니다.",
"archived_agent_banner": "{{name}}은(는) 보관되었습니다. 이 대화는 읽기 전용입니다.",
"offline_banner": {
"fallback_name": "에이전트",
"unstable": "{{name}}의 연결이 불안정합니다. 답변이 지연될 수 있습니다.",
@@ -138,7 +140,8 @@
"waiting": "답장 대기 중",
"pin": "고정",
"unpin": "고정 해제",
"pinned": "고정됨"
"pinned": "고정됨",
"archived": "보관됨"
},
"header": {
"view_profile": "Agent 프로필 보기",

View File

@@ -11,6 +11,7 @@
"input": {
"placeholder_no_agent": "创建一个智能体后才能开始对话",
"placeholder_archived": "此会话已归档",
"placeholder_archived_agent": "该智能体已归档",
"placeholder_named": "给 {{name}} 发消息…",
"placeholder_default": "输入消息…",
"send_tooltip": "发送",
@@ -102,6 +103,7 @@
"plan_next": "规划接下来该做什么"
},
"no_agent_banner": "需要先有一个智能体才能开始对话。",
"archived_agent_banner": "{{name}} 已归档——此会话为只读。",
"offline_banner": {
"fallback_name": "智能体",
"unstable": "{{name}} 的连接不稳定——回复可能延迟。",
@@ -138,7 +140,8 @@
"waiting": "等待回复",
"pin": "置顶",
"unpin": "取消置顶",
"pinned": "已置顶"
"pinned": "已置顶",
"archived": "已归档"
},
"header": {
"view_profile": "查看 Agent 资料",