From d252a77f404f6599a72de9eeff35457551f3ced2 Mon Sep 17 00:00:00 2001 From: Lambda Date: Wed, 8 Jul 2026 19:21:13 +0800 Subject: [PATCH] refactor(chat): drop chat-list archive marker, keep conversation read-only (MUL-4265) Co-authored-by: multica-agent --- .../chat/components/chat-thread-list.test.tsx | 166 ------------------ .../chat/components/chat-thread-list.tsx | 26 +-- packages/views/locales/en/chat.json | 3 +- packages/views/locales/ja/chat.json | 3 +- packages/views/locales/ko/chat.json | 3 +- packages/views/locales/zh-Hans/chat.json | 3 +- 6 files changed, 7 insertions(+), 197 deletions(-) delete mode 100644 packages/views/chat/components/chat-thread-list.test.tsx diff --git a/packages/views/chat/components/chat-thread-list.test.tsx b/packages/views/chat/components/chat-thread-list.test.tsx deleted file mode 100644 index 372c6529e5..0000000000 --- a/packages/views/chat/components/chat-thread-list.test.tsx +++ /dev/null @@ -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 }) => ( - - ), -})); - -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 9af987220c..870acab85c 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 { 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 ? ( - + ) : ( )} @@ -249,12 +235,6 @@ export function ChatThreadList({ 0 ? "font-semibold text-foreground" : "font-medium")}> {titleText} - {agentArchived && ( - - - {t(($) => $.list.archived)} - - )} {timeText} diff --git a/packages/views/locales/en/chat.json b/packages/views/locales/en/chat.json index a58f0062e4..96c2fb4426 100644 --- a/packages/views/locales/en/chat.json +++ b/packages/views/locales/en/chat.json @@ -143,8 +143,7 @@ "waiting": "Waiting for reply", "pin": "Pin", "unpin": "Unpin", - "pinned": "Pinned", - "archived": "Archived" + "pinned": "Pinned" }, "header": { "view_profile": "View agent profile", diff --git a/packages/views/locales/ja/chat.json b/packages/views/locales/ja/chat.json index 573860422a..b5ca5af784 100644 --- a/packages/views/locales/ja/chat.json +++ b/packages/views/locales/ja/chat.json @@ -140,8 +140,7 @@ "waiting": "返信待ち", "pin": "ピン留め", "unpin": "ピン留めを解除", - "pinned": "ピン留め済み", - "archived": "アーカイブ済み" + "pinned": "ピン留め済み" }, "header": { "view_profile": "Agent のプロフィール", diff --git a/packages/views/locales/ko/chat.json b/packages/views/locales/ko/chat.json index 8d5f9caf11..eec4860891 100644 --- a/packages/views/locales/ko/chat.json +++ b/packages/views/locales/ko/chat.json @@ -140,8 +140,7 @@ "waiting": "답장 대기 중", "pin": "고정", "unpin": "고정 해제", - "pinned": "고정됨", - "archived": "보관됨" + "pinned": "고정됨" }, "header": { "view_profile": "Agent 프로필 보기", diff --git a/packages/views/locales/zh-Hans/chat.json b/packages/views/locales/zh-Hans/chat.json index a44cebdd70..b7c64c7729 100644 --- a/packages/views/locales/zh-Hans/chat.json +++ b/packages/views/locales/zh-Hans/chat.json @@ -140,8 +140,7 @@ "waiting": "等待回复", "pin": "置顶", "unpin": "取消置顶", - "pinned": "已置顶", - "archived": "已归档" + "pinned": "已置顶" }, "header": { "view_profile": "查看 Agent 资料",