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 资料",