('[aria-label="입력란에 장소 추천 요청 문구 넣기"]')!.click());
+ await view.send();
+ expect(view.onSendAi).toHaveBeenCalledExactlyOnceWith("장소 추천해줘.");
+ expect(view.onSendChat).not.toHaveBeenCalled();
+});
+
+it("default retains typed AI mention activation", async () => {
+ const view = await renderInput(undefined);
+ await view.type("@ai 대화 요약해줘");
+ expect(view.input.value).toBe("대화 요약해줘");
+ await view.send();
+ expect(view.onSendAi).toHaveBeenCalledExactlyOnceWith("대화 요약해줘");
+});
diff --git a/src/components/chat/ChatInputBar.tsx b/src/components/chat/ChatInputBar.tsx
index 22553e57..e3412746 100644
--- a/src/components/chat/ChatInputBar.tsx
+++ b/src/components/chat/ChatInputBar.tsx
@@ -20,6 +20,7 @@ import {
CHAT_MESSAGE_TOO_LONG_TOAST,
} from "@/components/chat/chat-constants";
import { cn } from "@/lib/utils";
+import { CHAT_AI_ENABLED } from "@/lib/chat/ai-availability";
interface ChatInputBarProps {
isMinimized: boolean;
@@ -81,7 +82,7 @@ export function ChatInputBar({
const updateMentionFromDom = useCallback(
(v: string, cursor: number, aiOn: boolean) => {
- if (aiOn) {
+ if (!CHAT_AI_ENABLED || aiOn) {
setMentionOpen(false);
setMentionAnchor(null);
return;
@@ -125,6 +126,7 @@ export function ChatInputBar({
}, [mentionOpen]);
function toggleAi() {
+ if (!CHAT_AI_ENABLED) return;
setAiEnabled((prev) => {
const next = !prev;
if (next) {
@@ -156,7 +158,7 @@ export function ChatInputBar({
}
function pickMentionAi() {
- if (mentionAnchor === null) return;
+ if (!CHAT_AI_ENABLED || mentionAnchor === null) return;
const el = inputRef.current;
if (!el) return;
const v = el.value;
@@ -180,7 +182,7 @@ export function ChatInputBar({
const v = el.value;
const cursor = el.selectionStart ?? v.length;
- if (!aiEnabled && /^@ai(\s+|$)/i.test(v)) {
+ if (CHAT_AI_ENABLED && !aiEnabled && /^@ai(\s+|$)/i.test(v)) {
const rest = v.replace(/^@ai\s*/i, "");
setAiEnabled(true);
setMentionOpen(false);
@@ -217,7 +219,7 @@ export function ChatInputBar({
return;
}
- if (aiEnabled) onSendAi(trimmed);
+ if (CHAT_AI_ENABLED && aiEnabled) onSendAi(trimmed);
else onSendChat(trimmed);
setMessage("");
@@ -365,25 +367,27 @@ export function ChatInputBar({
)}
>
-
- {CHAT_AI_MENTION_LABEL}
- {aiEnabled && (
-
- )}
-
+ {CHAT_AI_ENABLED ? (
+
+ {CHAT_AI_MENTION_LABEL}
+ {aiEnabled && (
+
+ )}
+
+ ) : null}
{aiEnabled ? (
<>
{
+ vi.stubEnv("NEXT_PUBLIC_AI_ENABLED", "false");
+ return { publish: vi.fn(), track: vi.fn() };
+});
+vi.mock("@/contexts/StompContext", () => ({
+ useStompContext: () => ({ client: { publish: boundary.publish }, connected: true }),
+}));
+vi.mock("@/lib/analytics/track", () => ({
+ AnalyticsEvents: { chatMessageSent: "chat_message_sent" },
+ trackAnalyticsEvent: boundary.track,
+}));
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+afterEach(() => vi.unstubAllEnvs());
+
+it("QA off blocks direct AI sends/cancels while ordinary chat and place sharing still publish", async () => {
+ useSessionStore.setState({ currentRoomId: "room-qa" });
+ const hook = hookHarness(() => useChatActions());
+ try {
+ await hook.render(undefined);
+ expect(hook.current.canSend).toBe(true);
+ await act(async () => {
+ hook.current.sendAiMessage("추천해줘");
+ hook.current.sendCancelAiRequest("request-1");
+ });
+ expect(boundary.publish).not.toHaveBeenCalled();
+ expect(boundary.track).not.toHaveBeenCalled();
+
+ await act(async () => {
+ hook.current.sendChatMessage("일반 대화");
+ hook.current.sendPlaceMessage({
+ googlePlaceId: "place-1", name: "장소", formattedAddress: "주소",
+ latitude: 1, longitude: 2, rating: 4,
+ });
+ });
+ expect(boundary.publish.mock.calls.map(([frame]) => frame.destination)).toEqual([
+ "/app/rooms/room-qa/messages/chat", "/app/rooms/room-qa/messages/place",
+ ]);
+ expect(JSON.parse(boundary.publish.mock.calls[0][0].body).content).toBe("일반 대화");
+ expect(boundary.track.mock.calls.map(([, properties]) => properties.message_type)).toEqual(["text", "place"]);
+ } finally {
+ await hook.unmount();
+ }
+});
diff --git a/src/hooks/useChatActions.ts b/src/hooks/useChatActions.ts
index 373d3b8a..830b3f97 100644
--- a/src/hooks/useChatActions.ts
+++ b/src/hooks/useChatActions.ts
@@ -6,6 +6,7 @@ import { useStompContext } from "@/contexts/StompContext";
import { isChatSendBlocked } from "@/stores/chat-rate-limit-store";
import { useSessionStore } from "@/stores/session-store";
import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track";
+import { CHAT_AI_ENABLED } from "@/lib/chat/ai-availability";
import type { PlaceShareData } from "@/types/chat";
/**
@@ -49,6 +50,7 @@ export function useChatActions() {
const sendAiMessage = useCallback(
(content: string) => {
+ if (!CHAT_AI_ENABLED) return;
const trimmed = content.trim();
if (
!client ||
@@ -99,6 +101,7 @@ export function useChatActions() {
const sendCancelAiRequest = useCallback(
(requestMessageId: string) => {
+ if (!CHAT_AI_ENABLED) return;
const id = requestMessageId.trim();
if (!client || !connected || !ridTrimmed || !id) return;
client.publish({
diff --git a/src/lib/chat/ai-availability.ts b/src/lib/chat/ai-availability.ts
new file mode 100644
index 00000000..35466040
--- /dev/null
+++ b/src/lib/chat/ai-availability.ts
@@ -0,0 +1,2 @@
+// NEXT_PUBLIC values are fixed at build time; an omitted flag preserves AI access.
+export const CHAT_AI_ENABLED = process.env.NEXT_PUBLIC_AI_ENABLED !== "false";
diff --git a/src/lib/stomp/user-error-dispatch.ai-disabled.test.ts b/src/lib/stomp/user-error-dispatch.ai-disabled.test.ts
new file mode 100644
index 00000000..506bcd62
--- /dev/null
+++ b/src/lib/stomp/user-error-dispatch.ai-disabled.test.ts
@@ -0,0 +1,21 @@
+import { expect, it, vi } from "vitest";
+import { toast } from "sonner";
+import { dispatchUserError } from "./user-error-dispatch";
+import { parseUserErrorMessage } from "./user-error-events";
+
+vi.mock("sonner", () => ({ toast: { error: vi.fn() } }));
+
+it("accepts AI_DISABLED without suggesting a retry", () => {
+ const payload = parseUserErrorMessage(JSON.stringify({
+ domain: "MESSAGE", action: "SEND", code: "AI_DISABLED",
+ clientRequestId: "client-1", retryable: false,
+ message: "이 환경에서는 AI 기능을 제공하지 않습니다",
+ }));
+ expect(payload).not.toBeNull();
+ if (!payload) throw new Error("Expected the existing error contract to parse");
+ dispatchUserError(payload);
+ expect(toast.error).toHaveBeenCalledExactlyOnceWith(
+ "이 환경에서는 AI 기능을 제공하지 않습니다",
+ { duration: 4500 },
+ );
+});