diff --git a/packages/views/chat/chat-page.tsx b/packages/views/chat/chat-page.tsx
index ab496e0987..4a06d3c653 100644
--- a/packages/views/chat/chat-page.tsx
+++ b/packages/views/chat/chat-page.tsx
@@ -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 ? (
+ ) : c.isAgentArchived ? (
+
) : (
)}
@@ -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}
/>
diff --git a/packages/views/chat/components/archived-agent-banner.tsx b/packages/views/chat/components/archived-agent-banner.tsx
new file mode 100644
index 0000000000..dafb1d981d
--- /dev/null
+++ b/packages/views/chat/components/archived-agent-banner.tsx
@@ -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 (
+
+
+
+
+ {t(($) => $.archived_agent_banner, { name })}
+
+
+
+ );
+}
diff --git a/packages/views/chat/components/chat-input.tsx b/packages/views/chat/components/chat-input.tsx
index 898e9c8941..1b8d8fa4b7 100644
--- a/packages/views/chat/components/chat-input.tsx
+++ b/packages/views/chat/components/chat-input.tsx
@@ -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);
diff --git a/packages/views/chat/components/chat-thread-list.test.tsx b/packages/views/chat/components/chat-thread-list.test.tsx
new file mode 100644
index 0000000000..372c6529e5
--- /dev/null
+++ b/packages/views/chat/components/chat-thread-list.test.tsx
@@ -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 }) => (
+
+ ),
+}));
+
+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 & Pick): 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 & Pick): 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(
+
+
+
+
+ ,
+ );
+}
+
+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(
+
+
+
+
+ ,
+ );
+
+ expect(screen.queryByText(enChat.list.typing)).not.toBeInTheDocument();
+ // Falls back to the last-message preview instead.
+ expect(screen.getByText(/hello there/)).toBeInTheDocument();
+ });
+});
diff --git a/packages/views/chat/components/chat-thread-list.tsx b/packages/views/chat/components/chat-thread-list.tsx
index 870acab85c..9af987220c 100644
--- a/packages/views/chat/components/chat-thread-list.tsx
+++ b/packages/views/chat/components/chat-thread-list.tsx
@@ -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 ? (
-
+
) : (
)}
@@ -235,6 +249,12 @@ export function ChatThreadList({
0 ? "font-semibold text-foreground" : "font-medium")}>
{titleText}
+ {agentArchived && (
+
+
+ {t(($) => $.list.archived)}
+
+ )}
{timeText}
diff --git a/packages/views/chat/components/use-chat-controller.ts b/packages/views/chat/components/use-chat-controller.ts
index f0f8cf6581..ded5e2cf49 100644
--- a/packages/views/chat/components/use-chat-controller.ts
+++ b/packages/views/chat/components/use-chat-controller.ts
@@ -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,
diff --git a/packages/views/locales/en/chat.json b/packages/views/locales/en/chat.json
index c10f08c0e9..a58f0062e4 100644
--- a/packages/views/locales/en/chat.json
+++ b/packages/views/locales/en/chat.json
@@ -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",
diff --git a/packages/views/locales/ja/chat.json b/packages/views/locales/ja/chat.json
index ef63d945b7..573860422a 100644
--- a/packages/views/locales/ja/chat.json
+++ b/packages/views/locales/ja/chat.json
@@ -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 のプロフィール",
diff --git a/packages/views/locales/ko/chat.json b/packages/views/locales/ko/chat.json
index 975786c552..8d5f9caf11 100644
--- a/packages/views/locales/ko/chat.json
+++ b/packages/views/locales/ko/chat.json
@@ -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 프로필 보기",
diff --git a/packages/views/locales/zh-Hans/chat.json b/packages/views/locales/zh-Hans/chat.json
index ddf2f7fc98..a44cebdd70 100644
--- a/packages/views/locales/zh-Hans/chat.json
+++ b/packages/views/locales/zh-Hans/chat.json
@@ -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 资料",