mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 01:45:52 +02:00
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:
@@ -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>
|
||||
|
||||
26
packages/views/chat/components/archived-agent-banner.tsx
Normal file
26
packages/views/chat/components/archived-agent-banner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
166
packages/views/chat/components/chat-thread-list.test.tsx
Normal file
166
packages/views/chat/components/chat-thread-list.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 のプロフィール",
|
||||
|
||||
@@ -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 프로필 보기",
|
||||
|
||||
@@ -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 资料",
|
||||
|
||||
Reference in New Issue
Block a user