diff --git a/packages/core/permissions/use-resource-permissions.ts b/packages/core/permissions/use-resource-permissions.ts
index df3ef3cac6..02fa1285be 100644
--- a/packages/core/permissions/use-resource-permissions.ts
+++ b/packages/core/permissions/use-resource-permissions.ts
@@ -34,15 +34,21 @@ export function useAgentPermissions(
): {
canEdit: Decision;
canAssign: Decision;
+ isLoading: boolean;
} {
- const { userId, role } = useCurrentMember(wsId);
+ const { userId, role, isLoading } = useCurrentMember(wsId);
const ctx = { userId, role };
+ // While the member query is in flight, `role` is null and the rules below
+ // would misread a legitimate member as denied (e.g. `not_member` for a
+ // public_to+workspace agent). Callers with always-clickable affordances
+ // must treat `isLoading` as "undetermined", not as a deny.
if (agent === null) {
- return { canEdit: PENDING, canAssign: PENDING };
+ return { canEdit: PENDING, canAssign: PENDING, isLoading };
}
return {
canEdit: canEditAgent(agent, ctx),
canAssign: canAssignAgentToIssue(agent, ctx),
+ isLoading,
};
}
diff --git a/packages/views/agents/components/agent-detail-page.test.tsx b/packages/views/agents/components/agent-detail-page.test.tsx
index 0e153d969b..27fe4073c3 100644
--- a/packages/views/agents/components/agent-detail-page.test.tsx
+++ b/packages/views/agents/components/agent-detail-page.test.tsx
@@ -29,6 +29,9 @@ vi.mock("./agent-presence-indicator", () => ({
const agentsRef = vi.hoisted(() => ({ current: [] as unknown[] }));
const membersRef = vi.hoisted(() => ({ current: [] as unknown[] }));
+// When set, the member query never resolves — the "membership still loading"
+// window in which the DM decision is undetermined.
+const membersPendingRef = vi.hoisted(() => ({ current: false }));
const currentUserRef = vi.hoisted(() => ({
current: { id: "user-1" } as { id: string } | null,
}));
@@ -48,7 +51,10 @@ vi.mock("@multica/core/workspace/queries", () => ({
}),
memberListOptions: (wsId: string) => ({
queryKey: ["members", wsId],
- queryFn: () => Promise.resolve(membersRef.current),
+ queryFn: () =>
+ membersPendingRef.current
+ ? new Promise(() => {})
+ : Promise.resolve(membersRef.current),
}),
workspaceKeys: { agents: (wsId: string) => ["agents", wsId] },
}));
@@ -152,6 +158,7 @@ beforeEach(() => {
vi.clearAllMocks();
currentUserRef.current = { id: "user-1" };
membersRef.current = [{ user_id: "user-1", role: "member" }];
+ membersPendingRef.current = false;
agentsRef.current = [baseAgent];
});
@@ -179,6 +186,19 @@ describe("AgentDetailPage DM button", () => {
expect(push).not.toHaveBeenCalled();
});
+ it("disables DM while membership is resolving instead of toasting a false deny", async () => {
+ // Review P2: a pending member query collapses role to null, which the
+ // rules read as not_member — a legitimate public_to+workspace member
+ // would get a wrong "no access" toast. Undetermined must disable, not deny.
+ membersPendingRef.current = true;
+ const { push } = renderPage();
+ const dm = await screen.findByRole("button", { name: "DM" });
+ expect(dm).toBeDisabled();
+ fireEvent.click(dm);
+ expect(mockToastError).not.toHaveBeenCalled();
+ expect(push).not.toHaveBeenCalled();
+ });
+
it("hides the DM button on an archived agent", async () => {
agentsRef.current = [
{ ...baseAgent, archived_at: "2026-06-01T00:00:00Z" },
diff --git a/packages/views/agents/components/agent-detail-page.tsx b/packages/views/agents/components/agent-detail-page.tsx
index a457665f81..8e7bb6ffb2 100644
--- a/packages/views/agents/components/agent-detail-page.tsx
+++ b/packages/views/agents/components/agent-detail-page.tsx
@@ -107,7 +107,11 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
// signature handles the not-found / loading case internally so the early
// returns below don't violate the rules of hooks. Backend gates archive
// and restore identically to edit, so a single `canEdit` covers them all.
- const { canAssign, canEdit } = useAgentPermissions(agent, wsId);
+ const {
+ canAssign,
+ canEdit,
+ isLoading: permissionsLoading,
+ } = useAgentPermissions(agent, wsId);
const [confirmArchive, setConfirmArchive] = useState(false);
@@ -256,8 +260,11 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
// Chat shares the invocation gate with assignment (MUL-3963): starting a
// chat triggers agent runs. The button stays visible either way — a denied
- // click explains itself instead of the affordance silently missing.
+ // click explains itself instead of the affordance silently missing. While
+ // membership is still resolving the decision is undetermined, so the button
+ // is disabled rather than toasting a false "no access" at a real member.
const handleDm = () => {
+ if (permissionsLoading) return;
if (!canAssign.allowed) {
toast.error(t(($) => $.detail.dm_no_permission_toast));
return;
@@ -274,6 +281,7 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
backHref={paths.agents()}
canAssign={canAssign.allowed}
canArchive={canEdit.allowed}
+ dmPending={permissionsLoading}
onDm={handleDm}
onAssign={() =>
useModalStore
@@ -380,6 +388,7 @@ function DetailHeader({
backHref,
canAssign,
canArchive,
+ dmPending,
onDm,
onAssign,
onArchive,
@@ -390,6 +399,7 @@ function DetailHeader({
backHref: string;
canAssign: boolean;
canArchive: boolean;
+ dmPending: boolean;
onDm: () => void;
onAssign: () => void;
onArchive: () => void;
@@ -457,6 +467,7 @@ function DetailHeader({
type="button"
variant="outline"
size="sm"
+ disabled={dmPending}
onClick={onDm}
>
diff --git a/packages/views/chat/chat-page.test.tsx b/packages/views/chat/chat-page.test.tsx
index 8018bef9ac..8c14cb7103 100644
--- a/packages/views/chat/chat-page.test.tsx
+++ b/packages/views/chat/chat-page.test.tsx
@@ -1,7 +1,8 @@
// @vitest-environment jsdom
+import { StrictMode } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen } from "@testing-library/react";
import type { Agent } from "@multica/core/types";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "../locales/en/common.json";
@@ -15,7 +16,9 @@ const TEST_RESOURCES = { en: { common: enCommon, chat: enChat } };
// These tests target the page-level URL wiring (`?agent=` / `?session=`), so
// the conversation internals are stubbed and the controller is replaced with
-// a ref-driven fake the tests can steer.
+// a ref-driven fake the tests can steer. The thread-list stub stays
+// interactive: selecting a thread is the user action that must supersede a
+// pending `?agent=` intent.
vi.mock("./components/chat-message-list", () => ({
ChatMessageList: () =>
chat-message-list
,
ChatMessageSkeleton: () => chat-message-skeleton
,
@@ -24,7 +27,18 @@ vi.mock("./components/chat-input", () => ({
ChatInput: () => chat-input
,
}));
vi.mock("./components/chat-thread-list", () => ({
- ChatThreadList: () => chat-thread-list
,
+ ChatThreadList: ({
+ onSelectSession,
+ }: {
+ onSelectSession: (s: { id: string; agent_id: string }) => void;
+ }) => (
+
+ ),
}));
vi.mock("./components/chat-session-header", () => ({
ChatSessionHeader: () => chat-session-header
,
@@ -63,16 +77,34 @@ vi.mock("@multica/core/paths", () => ({
useWorkspacePaths: () => ({ chat: () => "/acme/chat" }),
}));
+// The store mock is REACTIVE like real Zustand: setActiveSession replaces the
+// snapshot and notifies subscribers, and the controller mock subscribes via
+// useSyncExternalStore. A plain mutable ref would let React bail out of
+// committing the post-effect re-render (no React state changed), leaving the
+// DOM frozen on the first commit — which silently hides exactly the class of
+// bug the StrictMode regression below exists to catch.
const storeRef = vi.hoisted(() => ({
current: { activeSessionId: null as string | null },
}));
+const storeListeners = vi.hoisted(() => new Set<() => void>());
const availableAgentsRef = vi.hoisted(() => ({ current: [] as Agent[] }));
+const agentsSettledRef = vi.hoisted(() => ({ current: true }));
const mockStartNewChat = vi.hoisted(() => vi.fn());
+const mockToastError = vi.hoisted(() => vi.fn());
const mockSetActiveSession = vi.hoisted(() =>
vi.fn((id: string | null) => {
- storeRef.current.activeSessionId = id;
+ storeRef.current = { ...storeRef.current, activeSessionId: id };
+ storeListeners.forEach((l) => l());
}),
);
+const subscribeToStore = vi.hoisted(() => (cb: () => void) => {
+ storeListeners.add(cb);
+ return () => storeListeners.delete(cb);
+});
+
+vi.mock("sonner", () => ({
+ toast: { success: vi.fn(), error: mockToastError },
+}));
vi.mock("@multica/core/chat", () => ({
useChatStore: Object.assign(
@@ -82,45 +114,52 @@ vi.mock("@multica/core/chat", () => ({
),
}));
-vi.mock("./components/use-chat-controller", () => ({
- useChatController: () => ({
- wsId: "ws-1",
- user: { id: "user-1" },
- agents: availableAgentsRef.current,
- availableAgents: availableAgentsRef.current,
- sessions: [],
- activeSessionId: storeRef.current.activeSessionId,
- selectedAgentId: null,
- currentSession: null,
- isSessionArchived: false,
- isAgentArchived: false,
- activeAgent: availableAgentsRef.current[0] ?? null,
- noAgent: false,
- availability: "online",
- messages: [],
- pendingTask: null,
- pendingTaskId: null,
- showSkeleton: false,
- hasMessages: false,
- firstItemIndex: 0,
- hasOlderMessages: false,
- isFetchingOlderMessages: false,
- fetchOlderMessages: vi.fn(),
- restoreDraftRequest: null,
- handleRestoreDraftConsumed: vi.fn(),
- focusInputRequest: 0,
- handleSend: vi.fn(),
- handleStop: vi.fn(),
- handleUploadFile: vi.fn(),
- handleNewChat: vi.fn(),
- handleStartNewChat: mockStartNewChat,
- handleSelectSession: vi.fn(),
- advanceSelectionAfterArchive: vi.fn(),
- archiveSession: vi.fn(),
- setActiveSession: mockSetActiveSession,
- setSelectedAgentId: vi.fn(),
- }),
-}));
+vi.mock("./components/use-chat-controller", async () => {
+ const { useSyncExternalStore } = await import("react");
+ return {
+ useChatController: () => ({
+ wsId: "ws-1",
+ user: { id: "user-1" },
+ agents: availableAgentsRef.current,
+ availableAgents: availableAgentsRef.current,
+ agentsSettled: agentsSettledRef.current,
+ sessions: [],
+ activeSessionId: useSyncExternalStore(
+ subscribeToStore,
+ () => storeRef.current,
+ ).activeSessionId,
+ selectedAgentId: null,
+ currentSession: null,
+ isSessionArchived: false,
+ isAgentArchived: false,
+ activeAgent: availableAgentsRef.current[0] ?? null,
+ noAgent: false,
+ availability: "online",
+ messages: [],
+ pendingTask: null,
+ pendingTaskId: null,
+ showSkeleton: false,
+ hasMessages: false,
+ firstItemIndex: 0,
+ hasOlderMessages: false,
+ isFetchingOlderMessages: false,
+ fetchOlderMessages: vi.fn(),
+ restoreDraftRequest: null,
+ handleRestoreDraftConsumed: vi.fn(),
+ focusInputRequest: 0,
+ handleSend: vi.fn(),
+ handleStop: vi.fn(),
+ handleUploadFile: vi.fn(),
+ handleNewChat: vi.fn(),
+ handleStartNewChat: mockStartNewChat,
+ handleSelectSession: vi.fn(),
+ advanceSelectionAfterArchive: vi.fn(),
+ archiveSession: vi.fn(),
+ setActiveSession: mockSetActiveSession,
+ setSelectedAgentId: vi.fn(),
+ }),
+ };
+});
import { ChatPage } from "./chat-page";
@@ -149,7 +188,9 @@ const agent: Agent = {
archived_by: null,
};
-function renderPage(search: string) {
+const NO_ACCESS_MSG = "You don't have access to chat with this agent.";
+
+function renderPage(search: string, { strict = false } = {}) {
const replace = vi.fn();
const navigation: NavigationAdapter = {
push: vi.fn(),
@@ -161,13 +202,16 @@ function renderPage(search: string) {
};
// A fresh element per render — reusing one element object lets React bail
// out of re-rendering, which would make the rerender-based tests vacuous.
- const makeUi = () => (
-
-
-
-
-
- );
+ const makeUi = () => {
+ const page = (
+
+
+
+
+
+ );
+ return strict ? {page} : page;
+ };
const view = render(makeUi());
return { replace, rerender: () => view.rerender(makeUi()) };
}
@@ -175,7 +219,9 @@ function renderPage(search: string) {
beforeEach(() => {
vi.clearAllMocks();
storeRef.current = { activeSessionId: null };
+ storeListeners.clear();
availableAgentsRef.current = [agent];
+ agentsSettledRef.current = true;
});
describe("ChatPage ?agent= deep link", () => {
@@ -199,22 +245,65 @@ describe("ChatPage ?agent= deep link", () => {
expect(mockStartNewChat).toHaveBeenCalledTimes(1);
});
+ it("stays pending without a toast while the agent/member queries load", () => {
+ availableAgentsRef.current = [];
+ agentsSettledRef.current = false;
+ const { replace } = renderPage("agent=agent-1");
+ expect(mockStartNewChat).not.toHaveBeenCalled();
+ expect(mockToastError).not.toHaveBeenCalled();
+ expect(replace).not.toHaveBeenCalled();
+ });
+
it("waits for the agent list to resolve before consuming the intent", () => {
availableAgentsRef.current = [];
+ agentsSettledRef.current = false;
const { replace, rerender } = renderPage("agent=agent-1");
expect(mockStartNewChat).not.toHaveBeenCalled();
- expect(replace).not.toHaveBeenCalled();
availableAgentsRef.current = [agent];
+ agentsSettledRef.current = true;
rerender();
expect(mockStartNewChat).toHaveBeenCalledWith(agent);
expect(replace).toHaveBeenCalledWith("/acme/chat");
});
- it("ignores an agent id that is not chat-able and keeps the default view", () => {
- availableAgentsRef.current = [agent];
- const { replace } = renderPage("agent=other-agent");
+ it("toasts and strips the param once the list settles without the agent", () => {
+ // Revoked access, archived agent, or a bad id: a settled miss must
+ // explain itself and consume the intent so a later refetch that surfaces
+ // the agent cannot auto-start a chat without a fresh click.
+ const { replace, rerender } = renderPage("agent=other-agent");
expect(mockStartNewChat).not.toHaveBeenCalled();
- expect(replace).not.toHaveBeenCalled();
+ expect(mockToastError).toHaveBeenCalledWith(NO_ACCESS_MSG);
+ expect(replace).toHaveBeenCalledWith("/acme/chat");
expect(screen.queryByText("chat-input")).not.toBeInTheDocument();
+ rerender();
+ expect(mockToastError).toHaveBeenCalledTimes(1);
+ });
+
+ it("lets an explicit thread selection supersede a still-pending intent", () => {
+ // Deferred-intent race (review P1): the user picks a thread while the
+ // agent/member queries are still loading; when they settle, the stale
+ // deep link must NOT fire and clobber that selection.
+ availableAgentsRef.current = [];
+ agentsSettledRef.current = false;
+ const { replace, rerender } = renderPage("agent=agent-1");
+ fireEvent.click(screen.getByRole("button", { name: "select-thread" }));
+ availableAgentsRef.current = [agent];
+ agentsSettledRef.current = true;
+ rerender();
+ expect(mockStartNewChat).not.toHaveBeenCalled();
+ expect(mockToastError).not.toHaveBeenCalled();
+ expect(replace).not.toHaveBeenCalled();
+ });
+
+ it("opens the compose pane under StrictMode with a persisted previous session", () => {
+ // StrictMode replays mount effects with render-captured values (review
+ // P2): the composingNew reset must read the LIVE store value, or the
+ // stale persisted session re-closes the pane the intent just opened —
+ // while the consumed-intent guard rightly refuses to fire again.
+ storeRef.current = { activeSessionId: "old-session" };
+ const { replace } = renderPage("agent=agent-1", { strict: true });
+ expect(mockStartNewChat).toHaveBeenCalledTimes(1);
+ expect(replace).toHaveBeenCalledWith("/acme/chat");
+ expect(screen.getByText("chat-input")).toBeInTheDocument();
});
});
diff --git a/packages/views/chat/chat-page.tsx b/packages/views/chat/chat-page.tsx
index 0fa9c8f49c..102a6cd073 100644
--- a/packages/views/chat/chat-page.tsx
+++ b/packages/views/chat/chat-page.tsx
@@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from "react";
import { useDefaultLayout } from "react-resizable-panels";
import { ArrowLeft, MessageSquare } from "lucide-react";
+import { toast } from "sonner";
import { Button } from "@multica/ui/components/ui/button";
import {
ResizablePanelGroup,
@@ -62,7 +63,12 @@ export function ChatPage() {
// real session takes over.
const [composingNew, setComposingNew] = useState(false);
useEffect(() => {
- if (c.activeSessionId) setComposingNew(false);
+ // Read the LIVE store value for the same reason as the session sync
+ // effects below: under StrictMode's double-invoke this effect replays
+ // with the render-captured snapshot, and a stale non-null session (a
+ // persisted chat the URL→store effect already cleared) would revert the
+ // composingNew=true that the later `?agent=` intent effect just set.
+ if (useChatStore.getState().activeSessionId) setComposingNew(false);
}, [c.activeSessionId]);
// Two-way sync between the URL (`?session=`) and the chat store's
@@ -98,7 +104,20 @@ export function ChatPage() {
id: "multica_chat_layout",
});
+ // `?agent=` intent bookkeeping. The ref holds the param value already
+ // consumed (or superseded) so the effect below fires at most once per deep
+ // link — it also bridges the async window between replace() and the
+ // searchParams actually dropping the param. Any explicit user action must
+ // supersede a still-pending intent: agent/member queries can resolve late,
+ // and a deferred intent firing after the user picked a thread (or started
+ // another chat) would clobber that choice.
+ const consumedAgentIntent = useRef(null);
+ const supersedeAgentIntent = () => {
+ if (urlAgent) consumedAgentIntent.current = urlAgent;
+ };
+
const handleSelect = (session: ChatSession) => {
+ supersedeAgentIntent();
c.handleSelectSession(session);
setComposingNew(false);
};
@@ -109,6 +128,7 @@ export function ChatPage() {
// the list, which reads more naturally than being thrown into an unrelated
// conversation full-screen. Archiving any other chat leaves the view put.
const handleArchive = (session: ChatSession) => {
+ supersedeAgentIntent();
if (session.id === c.activeSessionId) {
if (isMobile) {
c.setActiveSession(null);
@@ -121,6 +141,9 @@ export function ChatPage() {
};
const startNewChat = (agent: Agent | null) => {
+ // A manual ⊕ pick outranks a pending deep link; when called FROM the
+ // intent effect the ref is already set to this param, so this is a no-op.
+ supersedeAgentIntent();
if (agent) c.handleStartNewChat(agent);
else c.handleNewChat();
setComposingNew(true);
@@ -131,11 +154,11 @@ export function ChatPage() {
// agent. The permission-filtered agent list loads async, so the intent is
// consumed on the render where the agent resolves, then the param is
// stripped so refresh / the session sync above don't replay it. The ref
- // guards the window between replace() and the searchParams actually
- // updating; it resets once the param is gone so a later identical deep link
- // fires again. An id that never resolves (access revoked or agent archived
- // in the meantime) is ignored and the page stays on its default view.
- const consumedAgentIntent = useRef(null);
+ // resets once the param is gone so a later identical deep link fires again.
+ // A settled miss (access revoked, agent archived, bad id) is a denial: it
+ // explains itself with a toast and consumes the intent so a later refetch
+ // that surfaces the agent cannot start a chat without a fresh click. While
+ // the queries are still loading the intent simply stays pending.
useEffect(() => {
if (!urlAgent) {
consumedAgentIntent.current = null;
@@ -143,12 +166,19 @@ export function ChatPage() {
}
if (consumedAgentIntent.current === urlAgent) return;
const agent = c.availableAgents.find((a) => a.id === urlAgent);
- if (!agent) return;
- consumedAgentIntent.current = urlAgent;
- startNewChat(agent);
- replace(wsPaths.chat());
+ if (agent) {
+ consumedAgentIntent.current = urlAgent;
+ startNewChat(agent);
+ replace(wsPaths.chat());
+ return;
+ }
+ if (c.agentsSettled) {
+ consumedAgentIntent.current = urlAgent;
+ toast.error(t(($) => $.page.agent_link_no_access));
+ replace(wsPaths.chat());
+ }
// eslint-disable-next-line react-hooks/exhaustive-deps -- consume when the URL param or the resolving agent list changes
- }, [urlAgent, c.availableAgents]);
+ }, [urlAgent, c.availableAgents, c.agentsSettled]);
const newChatButton = (
s.setActiveSession);
const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId);
const user = useAuthStore((s) => s.user);
- const { data: agents = [] } = useQuery(agentListOptions(wsId));
- const { data: members = [] } = useQuery(memberListOptions(wsId));
+ const { data: agents = [], isSuccess: agentsLoaded } = useQuery(
+ agentListOptions(wsId),
+ );
+ const { data: members = [], isSuccess: membersLoaded } = useQuery(
+ memberListOptions(wsId),
+ );
const { data: sessions = [], isSuccess: sessionsLoaded } = useQuery(
chatSessionsOptions(wsId),
);
@@ -265,6 +269,13 @@ export function useChatController(opts?: { isActive?: boolean }) {
const availableAgents = agents.filter(
(a) => !a.archived_at && canAssignAgent(a, user?.id, memberRole),
);
+ // `availableAgents` is only trustworthy once BOTH queries above succeeded:
+ // the permission filter reads the member role, so agents-without-members
+ // misreports a public_to agent as unavailable. Consumers that must tell
+ // "still loading" apart from "settled and not available" (the `?agent=`
+ // deep link) gate on this instead of sniffing list emptiness. Query errors
+ // deliberately keep this false — a failed fetch is not a permission verdict.
+ const agentsSettled = agentsLoaded && membersLoaded;
// The agent bound to the OPEN session, resolved from the full agent list
// (archived included, since agentListOptions passes include_archived). An
@@ -655,6 +666,7 @@ export function useChatController(opts?: { isActive?: boolean }) {
user,
agents,
availableAgents,
+ agentsSettled,
sessions,
activeSessionId,
selectedAgentId,
diff --git a/packages/views/locales/en/chat.json b/packages/views/locales/en/chat.json
index 0dde77d11d..1aae491205 100644
--- a/packages/views/locales/en/chat.json
+++ b/packages/views/locales/en/chat.json
@@ -1,7 +1,8 @@
{
"page": {
"title": "Chat",
- "select_prompt": "Pick a conversation, or start a new one with +"
+ "select_prompt": "Pick a conversation, or start a new one with +",
+ "agent_link_no_access": "You don't have access to chat with this agent."
},
"fab": {
"running": "Multica is working...",
diff --git a/packages/views/locales/ja/chat.json b/packages/views/locales/ja/chat.json
index 3f77c7bf2f..a945a93991 100644
--- a/packages/views/locales/ja/chat.json
+++ b/packages/views/locales/ja/chat.json
@@ -1,7 +1,8 @@
{
"page": {
"title": "チャット",
- "select_prompt": "会話を選ぶか、+ で新規作成してください"
+ "select_prompt": "会話を選ぶか、+ で新規作成してください",
+ "agent_link_no_access": "このエージェントとチャットする権限がありません。"
},
"fab": {
"running": "Multica が作業中です...",
diff --git a/packages/views/locales/ko/chat.json b/packages/views/locales/ko/chat.json
index 7128b58a9f..7ac9974f32 100644
--- a/packages/views/locales/ko/chat.json
+++ b/packages/views/locales/ko/chat.json
@@ -1,7 +1,8 @@
{
"page": {
"title": "채팅",
- "select_prompt": "대화를 선택하거나 +로 새로 시작하세요"
+ "select_prompt": "대화를 선택하거나 +로 새로 시작하세요",
+ "agent_link_no_access": "이 에이전트와 채팅할 권한이 없습니다."
},
"fab": {
"running": "Multica가 작업 중입니다...",
diff --git a/packages/views/locales/zh-Hans/chat.json b/packages/views/locales/zh-Hans/chat.json
index 3647b1f1f1..b7afa89e56 100644
--- a/packages/views/locales/zh-Hans/chat.json
+++ b/packages/views/locales/zh-Hans/chat.json
@@ -1,7 +1,8 @@
{
"page": {
"title": "聊天",
- "select_prompt": "选择一个对话,或点 + 新建"
+ "select_prompt": "选择一个对话,或点 + 新建",
+ "agent_link_no_access": "你没有权限与该智能体聊天。"
},
"fab": {
"running": "Multica 正在工作...",