feat(FN-3812): add rooms test scaffolds and coverage across core and dashbo
This branch establishes test scaffolding for rooms functionality across core and dashboard, adding test plans, room-specific test files for ChatView, AgentMentionPopup, QuickChatFAB, and core chat routes/stores, while also updating AgentMentionPopup with enhanced room-aware behavior and QuickChatFAB Fusion-Task-Id: FN-3812
This commit is contained in:
@@ -38,8 +38,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
padding: calc(var(--space-sm) - (var(--space-xs) / 2)) var(--space-md);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 1.25);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
@@ -74,11 +74,29 @@
|
||||
}
|
||||
|
||||
.agent-mention-empty {
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
padding: calc(var(--space-sm) + (var(--space-xs) / 2)) var(--space-md);
|
||||
font-size: var(--space-md);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.agent-mention-section-header {
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.5);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.agent-mention-hint {
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
font-size: var(--space-md);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.agent-mention-member-dot {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.file-mention-popup-loading .spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
@@ -95,3 +113,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.agent-mention-popup {
|
||||
min-width: calc(var(--space-xl) * 9);
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@ interface AgentMentionPopupProps {
|
||||
onSelect: (agent: Agent) => void;
|
||||
/** Positioning anchor: "above" | "below" the input */
|
||||
position?: "above" | "below";
|
||||
/** Room-member ids when mentioning from a room context */
|
||||
roomMemberIds?: ReadonlySet<string>;
|
||||
/** Optional room name for room section labels */
|
||||
roomName?: string;
|
||||
}
|
||||
|
||||
export function AgentMentionPopup({
|
||||
@@ -26,9 +30,24 @@ export function AgentMentionPopup({
|
||||
visible,
|
||||
onSelect,
|
||||
position = "below",
|
||||
roomMemberIds,
|
||||
roomName,
|
||||
}: AgentMentionPopupProps) {
|
||||
const filteredAgents = useMemo(() => agents.filter((agent) => matchesAgentMentionFilter(agent.name, filter)), [agents, filter]);
|
||||
|
||||
const roomMode = Boolean(roomMemberIds);
|
||||
const showOtherSection = roomMode && filter.trim().length > 0;
|
||||
|
||||
const memberAgents = useMemo(
|
||||
() => roomMode ? filteredAgents.filter((agent) => roomMemberIds?.has(agent.id)) : filteredAgents,
|
||||
[filteredAgents, roomMemberIds, roomMode],
|
||||
);
|
||||
const otherAgents = useMemo(
|
||||
() => roomMode ? filteredAgents.filter((agent) => !roomMemberIds?.has(agent.id)) : [],
|
||||
[filteredAgents, roomMemberIds, roomMode],
|
||||
);
|
||||
const visibleAgents = showOtherSection ? [...memberAgents, ...otherAgents] : memberAgents;
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
@@ -40,25 +59,60 @@ export function AgentMentionPopup({
|
||||
role="listbox"
|
||||
aria-label="Agent mention suggestions"
|
||||
>
|
||||
{filteredAgents.length === 0 ? (
|
||||
{visibleAgents.length === 0 ? (
|
||||
<div className="agent-mention-empty">No agents found</div>
|
||||
) : (
|
||||
filteredAgents.map((agent, index) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
className={`agent-mention-item${index === highlightedIndex ? " agent-mention-item--highlighted" : ""}`}
|
||||
data-testid={`agent-mention-item-${agent.id}`}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onSelect(agent)}
|
||||
role="option"
|
||||
aria-selected={index === highlightedIndex}
|
||||
>
|
||||
<AgentAvatar agent={agent} size={20} />
|
||||
<span className="agent-mention-name">{agent.name}</span>
|
||||
<span className="agent-mention-role">{agent.role}</span>
|
||||
</button>
|
||||
))
|
||||
<>
|
||||
{roomMode && (
|
||||
<div className="agent-mention-section-header" data-testid="agent-mention-members-header">
|
||||
{roomName ? `Members of #${roomName}` : "Room members"}
|
||||
</div>
|
||||
)}
|
||||
{memberAgents.map((agent, index) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
className={`agent-mention-item${index === highlightedIndex ? " agent-mention-item--highlighted" : ""}`}
|
||||
data-testid={`agent-mention-item-${agent.id}`}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onSelect(agent)}
|
||||
role="option"
|
||||
aria-selected={index === highlightedIndex}
|
||||
>
|
||||
<AgentAvatar agent={agent} size={20} />
|
||||
{roomMode && <span className="status-dot agent-mention-member-dot" aria-label="Room member" />}
|
||||
<span className="agent-mention-name">{agent.name}</span>
|
||||
<span className="agent-mention-role">{agent.role}</span>
|
||||
</button>
|
||||
))}
|
||||
{roomMode && !showOtherSection && otherAgents.length > 0 && (
|
||||
<div className="agent-mention-hint" data-testid="agent-mention-other-hint">Type to search other agents</div>
|
||||
)}
|
||||
{roomMode && showOtherSection && otherAgents.length > 0 && (
|
||||
<>
|
||||
<div className="agent-mention-section-header" data-testid="agent-mention-others-header">Other agents</div>
|
||||
{otherAgents.map((agent, index) => {
|
||||
const globalIndex = memberAgents.length + index;
|
||||
return (
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
className={`agent-mention-item${globalIndex === highlightedIndex ? " agent-mention-item--highlighted" : ""}`}
|
||||
data-testid={`agent-mention-item-${agent.id}`}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onSelect(agent)}
|
||||
role="option"
|
||||
aria-selected={globalIndex === highlightedIndex}
|
||||
>
|
||||
<AgentAvatar agent={agent} size={20} />
|
||||
<span className="agent-mention-name">{agent.name}</span>
|
||||
<span className="agent-mention-role">{agent.role}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1438,3 +1438,24 @@
|
||||
max-width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-mention-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
margin: 0 var(--space-xs);
|
||||
background: color-mix(in srgb, var(--todo) 14%, transparent);
|
||||
color: var(--todo);
|
||||
border: var(--btn-border-width, 1px) solid color-mix(in srgb, var(--todo) 30%, transparent);
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.5);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-mention-chip--non-member {
|
||||
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
|
||||
color: var(--text-muted);
|
||||
border-color: color-mix(in srgb, var(--color-warning) 35%, transparent);
|
||||
}
|
||||
|
||||
@@ -551,6 +551,12 @@ function NewChatDialog({ projectId, onClose, onCreate }: NewChatDialogProps) {
|
||||
|
||||
type CopyFeedbackState = "success" | "error" | null;
|
||||
|
||||
interface RoomContext {
|
||||
roomId: string;
|
||||
roomName: string;
|
||||
memberIds: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
interface ChatMessageItemProps {
|
||||
message: ChatMessageInfo;
|
||||
/**
|
||||
@@ -572,6 +578,7 @@ interface ChatMessageItemProps {
|
||||
activeModelProvider: string | null;
|
||||
activeSessionId: string | null;
|
||||
mentionAgentsByName: Map<string, Agent>;
|
||||
roomContext: RoomContext | null;
|
||||
copyAction?: ReactNode;
|
||||
}
|
||||
|
||||
@@ -588,6 +595,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
||||
activeModelProvider,
|
||||
activeSessionId,
|
||||
mentionAgentsByName,
|
||||
roomContext,
|
||||
copyAction,
|
||||
}: ChatMessageItemProps) {
|
||||
const isAssistantMessage = message.role === "assistant";
|
||||
@@ -606,8 +614,15 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
||||
const normalizedName = rawName.replace(/_/g, " ").toLowerCase();
|
||||
const mentionedAgent = mentionAgentsByName.get(normalizedName);
|
||||
if (mentionedAgent) {
|
||||
const isNonMember = Boolean(roomContext && !roomContext.memberIds.has(mentionedAgent.id));
|
||||
const nonMemberLabel = isNonMember ? `Not a member of ${roomContext?.roomName}` : undefined;
|
||||
parts.push(
|
||||
<span key={`${mentionedAgent.id}-${start}`} className="chat-mention-chip">
|
||||
<span
|
||||
key={`${mentionedAgent.id}-${start}`}
|
||||
className={`chat-mention-chip${isNonMember ? " chat-mention-chip--non-member" : ""}`}
|
||||
title={nonMemberLabel}
|
||||
aria-label={nonMemberLabel}
|
||||
>
|
||||
@{mentionedAgent.name.replace(/\s+/g, "_")}
|
||||
</span>,
|
||||
);
|
||||
@@ -619,7 +634,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
||||
}
|
||||
if (lastIndex < content.length) parts.push(content.slice(lastIndex));
|
||||
return parts.length === 0 ? content : parts;
|
||||
}, [isAssistantMessage, message.content, mentionAgentsByName]);
|
||||
}, [isAssistantMessage, message.content, mentionAgentsByName, roomContext]);
|
||||
|
||||
const renderedAttachments = useMemo<ReactNode>(() => {
|
||||
const attachments = message.attachments;
|
||||
@@ -869,10 +884,31 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
|
||||
const mentionAgents = useMemo(() => Array.from(agentsMap.values()), [agentsMap]);
|
||||
|
||||
const filteredMentionAgents = useMemo(
|
||||
() => mentionAgents.filter((agent) => matchesAgentMentionFilter(agent.name, mentionFilter)),
|
||||
[mentionAgents, mentionFilter],
|
||||
);
|
||||
const roomContext = useMemo<RoomContext | null>(() => {
|
||||
if (!chatRoomsEnabled || chatScope !== "rooms" || !rooms.activeRoom) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
roomId: rooms.activeRoom.id,
|
||||
roomName: rooms.activeRoom.name,
|
||||
memberIds: new Set(rooms.activeRoomMembers.map((member) => member.agentId)),
|
||||
};
|
||||
}, [chatRoomsEnabled, chatScope, rooms.activeRoom, rooms.activeRoomMembers]);
|
||||
|
||||
const filteredMentionAgents = useMemo(() => {
|
||||
const matchingAgents = mentionAgents.filter((agent) => matchesAgentMentionFilter(agent.name, mentionFilter));
|
||||
if (!roomContext) {
|
||||
return matchingAgents;
|
||||
}
|
||||
|
||||
const memberAgents = matchingAgents.filter((agent) => roomContext.memberIds.has(agent.id));
|
||||
if (mentionFilter.trim().length === 0) {
|
||||
return memberAgents;
|
||||
}
|
||||
|
||||
const otherAgents = matchingAgents.filter((agent) => !roomContext.memberIds.has(agent.id));
|
||||
return [...memberAgents, ...otherAgents];
|
||||
}, [mentionAgents, mentionFilter, roomContext]);
|
||||
|
||||
const mentionAgentsByName = useMemo(() => {
|
||||
const byName = new Map<string, Agent>();
|
||||
@@ -2103,6 +2139,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
activeModelProvider={null}
|
||||
activeSessionId={rooms.activeRoom?.id ?? null}
|
||||
mentionAgentsByName={mentionAgentsByName}
|
||||
roomContext={roomContext}
|
||||
/>
|
||||
);
|
||||
})
|
||||
@@ -2128,6 +2165,16 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
rows={1}
|
||||
data-testid="chat-input"
|
||||
/>
|
||||
<AgentMentionPopup
|
||||
agents={mentionAgents}
|
||||
filter={mentionFilter}
|
||||
highlightedIndex={mentionHighlightIndex}
|
||||
visible={mentionPopupVisible}
|
||||
onSelect={handleMentionSelect}
|
||||
position="below"
|
||||
roomMemberIds={roomContext?.memberIds}
|
||||
roomName={roomContext?.roomName}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -2204,6 +2251,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
activeModelProvider={activeModelProvider}
|
||||
activeSessionId={activeSession?.id ?? null}
|
||||
mentionAgentsByName={mentionAgentsByName}
|
||||
roomContext={null}
|
||||
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
|
||||
/>
|
||||
))}
|
||||
@@ -2259,6 +2307,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
activeModelProvider={activeModelProvider}
|
||||
activeSessionId={activeSession?.id ?? null}
|
||||
mentionAgentsByName={mentionAgentsByName}
|
||||
roomContext={null}
|
||||
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
|
||||
/>
|
||||
))}
|
||||
@@ -2399,6 +2448,8 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
visible={mentionPopupVisible}
|
||||
onSelect={handleMentionSelect}
|
||||
position="below"
|
||||
roomMemberIds={roomContext?.memberIds}
|
||||
roomName={roomContext?.roomName}
|
||||
/>
|
||||
<FileMentionPopup
|
||||
visible={fileMention.mentionActive && !mentionPopupVisible}
|
||||
|
||||
@@ -1206,6 +1206,12 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quick-chat-panel .chat-mention-chip--non-member {
|
||||
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
|
||||
color: var(--text-muted);
|
||||
border-color: color-mix(in srgb, var(--color-warning) 35%, transparent);
|
||||
}
|
||||
|
||||
.quick-chat-panel-message--streaming {
|
||||
position: relative;
|
||||
opacity: 0.95;
|
||||
|
||||
@@ -33,6 +33,11 @@ interface PendingAttachment {
|
||||
previewUrl: string;
|
||||
}
|
||||
|
||||
interface QuickChatRoomContext {
|
||||
roomName: string;
|
||||
memberIds: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
interface QuickChatFABProps {
|
||||
projectId?: string;
|
||||
addToast: (msg: string, type?: "success" | "error" | "warning") => void;
|
||||
@@ -50,6 +55,8 @@ interface QuickChatFABProps {
|
||||
onToggleFavorite?: (provider: string) => void;
|
||||
/** Called when user toggles a model's favorite status */
|
||||
onToggleModelFavorite?: (modelId: string) => void;
|
||||
/** Optional room context for member-aware mention UX */
|
||||
roomContext?: QuickChatRoomContext | null;
|
||||
}
|
||||
|
||||
interface ParsedModelSelection {
|
||||
@@ -752,6 +759,7 @@ interface QuickChatMessageItemProps {
|
||||
message: ChatMessageInfo;
|
||||
forcePlain: boolean;
|
||||
mentionAgentsByName: Map<string, Agent>;
|
||||
roomContext: QuickChatRoomContext | null;
|
||||
onToggleRender: (id: string) => void;
|
||||
}
|
||||
|
||||
@@ -761,6 +769,7 @@ const QuickChatMessageItem = memo(function QuickChatMessageItem({
|
||||
message,
|
||||
forcePlain,
|
||||
mentionAgentsByName,
|
||||
roomContext,
|
||||
onToggleRender,
|
||||
}: QuickChatMessageItemProps) {
|
||||
const isSent = message.role === "user";
|
||||
@@ -779,8 +788,15 @@ const QuickChatMessageItem = memo(function QuickChatMessageItem({
|
||||
const normalizedName = rawName.replace(/_/g, " ").toLowerCase();
|
||||
const mentionedAgent = mentionAgentsByName.get(normalizedName);
|
||||
if (mentionedAgent) {
|
||||
const isNonMember = Boolean(roomContext && !roomContext.memberIds.has(mentionedAgent.id));
|
||||
const nonMemberLabel = isNonMember ? `Not a member of ${roomContext?.roomName}` : undefined;
|
||||
parts.push(
|
||||
<span key={`${mentionedAgent.id}-${start}`} className="chat-mention-chip">
|
||||
<span
|
||||
key={`${mentionedAgent.id}-${start}`}
|
||||
className={`chat-mention-chip${isNonMember ? " chat-mention-chip--non-member" : ""}`}
|
||||
title={nonMemberLabel}
|
||||
aria-label={nonMemberLabel}
|
||||
>
|
||||
@{mentionedAgent.name.replace(/\s+/g, "_")}
|
||||
</span>,
|
||||
);
|
||||
@@ -792,7 +808,7 @@ const QuickChatMessageItem = memo(function QuickChatMessageItem({
|
||||
}
|
||||
if (lastIndex < content.length) parts.push(content.slice(lastIndex));
|
||||
return parts.length === 0 ? content : parts;
|
||||
}, [isSent, message.content, mentionAgentsByName]);
|
||||
}, [isSent, message.content, mentionAgentsByName, roomContext]);
|
||||
|
||||
const assistantBody = useMemo<ReactNode>(() => {
|
||||
if (isSent) return null;
|
||||
@@ -844,6 +860,7 @@ export function QuickChatFAB({
|
||||
favoriteModels = [],
|
||||
onToggleFavorite,
|
||||
onToggleModelFavorite,
|
||||
roomContext = null,
|
||||
}: QuickChatFABProps) {
|
||||
const { agents } = useAgents(projectId);
|
||||
// Internal state for uncontrolled mode, controlled state when open prop is provided
|
||||
@@ -1345,10 +1362,20 @@ export function QuickChatFAB({
|
||||
return matchingSkills.slice(0, 10);
|
||||
}, [discoveredSkills, skillFilter]);
|
||||
|
||||
const filteredMentionAgents = useMemo(
|
||||
() => agents.filter((agent) => matchesAgentMentionFilter(agent.name, mentionFilter)),
|
||||
[agents, mentionFilter],
|
||||
);
|
||||
const filteredMentionAgents = useMemo(() => {
|
||||
const matchingAgents = agents.filter((agent) => matchesAgentMentionFilter(agent.name, mentionFilter));
|
||||
if (!roomContext) {
|
||||
return matchingAgents;
|
||||
}
|
||||
|
||||
const memberAgents = matchingAgents.filter((agent) => roomContext.memberIds.has(agent.id));
|
||||
if (mentionFilter.trim().length === 0) {
|
||||
return memberAgents;
|
||||
}
|
||||
|
||||
const otherAgents = matchingAgents.filter((agent) => !roomContext.memberIds.has(agent.id));
|
||||
return [...memberAgents, ...otherAgents];
|
||||
}, [agents, mentionFilter, roomContext]);
|
||||
|
||||
const mentionAgentsByName = useMemo(() => {
|
||||
const byName = new Map<string, Agent>();
|
||||
@@ -2349,6 +2376,7 @@ export function QuickChatFAB({
|
||||
message={message}
|
||||
forcePlain={message.role !== "user" && plainTextMessageIds.has(message.id)}
|
||||
mentionAgentsByName={mentionAgentsByName}
|
||||
roomContext={roomContext}
|
||||
onToggleRender={toggleMessageRenderMode}
|
||||
/>
|
||||
))}
|
||||
@@ -2402,6 +2430,7 @@ export function QuickChatFAB({
|
||||
message={message}
|
||||
forcePlain={message.role !== "user" && plainTextMessageIds.has(message.id)}
|
||||
mentionAgentsByName={mentionAgentsByName}
|
||||
roomContext={roomContext}
|
||||
onToggleRender={toggleMessageRenderMode}
|
||||
/>
|
||||
))}
|
||||
@@ -2593,6 +2622,8 @@ export function QuickChatFAB({
|
||||
visible={mentionPopupVisible}
|
||||
onSelect={handleMentionSelect}
|
||||
position="above"
|
||||
roomMemberIds={roomContext?.memberIds}
|
||||
roomName={roomContext?.roomName}
|
||||
/>
|
||||
<FileMentionPopup
|
||||
visible={fileMention.mentionActive && !mentionPopupVisible}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Agent } from "@fusion/core";
|
||||
import { AgentMentionPopup } from "../AgentMentionPopup";
|
||||
|
||||
const agents: Agent[] = [
|
||||
{ id: "agent-001", name: "Alpha", role: "executor", state: "idle", createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", metadata: {} },
|
||||
{ id: "agent-002", name: "Alfred", role: "reviewer", state: "idle", createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", metadata: {} },
|
||||
{ id: "agent-003", name: "Alex", role: "triage", state: "idle", createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", metadata: {} },
|
||||
];
|
||||
|
||||
describe("AgentMentionPopup room behavior", () => {
|
||||
it("shows only members with hint on empty filter", () => {
|
||||
render(
|
||||
<AgentMentionPopup
|
||||
agents={agents}
|
||||
filter=""
|
||||
highlightedIndex={0}
|
||||
visible={true}
|
||||
onSelect={vi.fn()}
|
||||
roomMemberIds={new Set(["agent-001", "agent-003"])}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("agent-mention-item-agent-001")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("agent-mention-item-agent-003")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("agent-mention-item-agent-002")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("agent-mention-other-hint")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows matching members before matching non-members when filtering", () => {
|
||||
render(
|
||||
<AgentMentionPopup
|
||||
agents={agents}
|
||||
filter="al"
|
||||
highlightedIndex={0}
|
||||
visible={true}
|
||||
onSelect={vi.fn()}
|
||||
roomMemberIds={new Set(["agent-003"])}
|
||||
/>,
|
||||
);
|
||||
|
||||
const options = screen.getAllByRole("option");
|
||||
expect(options[0]).toHaveAttribute("data-testid", "agent-mention-item-agent-003");
|
||||
expect(options[1]).toHaveAttribute("data-testid", "agent-mention-item-agent-001");
|
||||
});
|
||||
|
||||
it("supports keyboard-index traversal across members then non-members", () => {
|
||||
const roomMemberIds = new Set(["agent-003"]);
|
||||
const { rerender } = render(
|
||||
<AgentMentionPopup
|
||||
agents={agents}
|
||||
filter="al"
|
||||
highlightedIndex={0}
|
||||
visible={true}
|
||||
onSelect={vi.fn()}
|
||||
roomMemberIds={roomMemberIds}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("agent-mention-item-agent-003")).toHaveClass("agent-mention-item--highlighted");
|
||||
|
||||
rerender(
|
||||
<AgentMentionPopup
|
||||
agents={agents}
|
||||
filter="al"
|
||||
highlightedIndex={1}
|
||||
visible={true}
|
||||
onSelect={vi.fn()}
|
||||
roomMemberIds={roomMemberIds}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("agent-mention-item-agent-001")).toHaveClass("agent-mention-item--highlighted");
|
||||
});
|
||||
|
||||
it("selects non-members and includes accessible member dot labels", () => {
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<AgentMentionPopup
|
||||
agents={agents}
|
||||
filter="al"
|
||||
highlightedIndex={0}
|
||||
visible={true}
|
||||
onSelect={onSelect}
|
||||
roomMemberIds={new Set(["agent-001"])}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("agent-mention-item-agent-002"));
|
||||
expect(onSelect).toHaveBeenCalledWith(agents[1]);
|
||||
expect(screen.getAllByLabelText("Room member")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,15 @@ const agents: Agent[] = [
|
||||
updatedAt: "2026-04-01T00:00:00.000Z",
|
||||
metadata: {},
|
||||
},
|
||||
{
|
||||
id: "agent-003",
|
||||
name: "Gamma",
|
||||
role: "triage",
|
||||
state: "idle",
|
||||
createdAt: "2026-04-01T00:00:00.000Z",
|
||||
updatedAt: "2026-04-01T00:00:00.000Z",
|
||||
metadata: {},
|
||||
},
|
||||
];
|
||||
|
||||
describe("AgentMentionPopup", () => {
|
||||
@@ -119,6 +128,57 @@ describe("AgentMentionPopup", () => {
|
||||
expect(screen.getByText("No agents found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows room sections with members first and member dot label", () => {
|
||||
render(
|
||||
<AgentMentionPopup
|
||||
agents={agents}
|
||||
filter="a"
|
||||
highlightedIndex={0}
|
||||
visible={true}
|
||||
onSelect={vi.fn()}
|
||||
roomMemberIds={new Set(["agent-003", "agent-001"])}
|
||||
roomName="engineering"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("agent-mention-members-header")).toHaveTextContent("Members of #engineering");
|
||||
expect(screen.getByTestId("agent-mention-others-header")).toBeInTheDocument();
|
||||
expect(screen.getAllByLabelText("Room member")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("room mode hides other section when filter is empty and still allows selecting non-member when searching", () => {
|
||||
const onSelect = vi.fn();
|
||||
const roomMemberIds = new Set(["agent-001"]);
|
||||
const { rerender } = render(
|
||||
<AgentMentionPopup
|
||||
agents={agents}
|
||||
filter=""
|
||||
highlightedIndex={0}
|
||||
visible={true}
|
||||
onSelect={onSelect}
|
||||
roomMemberIds={roomMemberIds}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("agent-mention-others-header")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("agent-mention-other-hint")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("agent-mention-item-agent-002")).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<AgentMentionPopup
|
||||
agents={agents}
|
||||
filter="be"
|
||||
highlightedIndex={1}
|
||||
visible={true}
|
||||
onSelect={onSelect}
|
||||
roomMemberIds={roomMemberIds}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("agent-mention-item-agent-002"));
|
||||
expect(onSelect).toHaveBeenCalledWith(agents[1]);
|
||||
});
|
||||
|
||||
it("renders nothing when visible is false", () => {
|
||||
render(
|
||||
<AgentMentionPopup
|
||||
|
||||
@@ -1,200 +1,18 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { ChatView } from "../ChatView";
|
||||
import * as useChatModule from "../../hooks/useChat";
|
||||
import * as useChatRoomsModule from "../../hooks/useChatRooms";
|
||||
import * as headerModule from "../Header";
|
||||
import type { UseChatRoomsResult } from "../../hooks/useChatRooms";
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
vi.mock("../../hooks/useChat", () => ({ useChat: vi.fn() }));
|
||||
vi.mock("../../hooks/useChatRooms", () => ({ useChatRooms: vi.fn() }));
|
||||
vi.mock("../Header", () => ({ useViewportMode: vi.fn() }));
|
||||
vi.mock("../../api", () => ({
|
||||
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
|
||||
fetchAgents: vi.fn().mockResolvedValue([{ id: "agent-1", name: "Alpha", role: "executor", state: "idle", metadata: {}, createdAt: "", updatedAt: "" }]),
|
||||
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
|
||||
updateGlobalSettings: vi.fn(),
|
||||
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
|
||||
}));
|
||||
|
||||
const mockUseChat = vi.mocked(useChatModule.useChat);
|
||||
const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms);
|
||||
const mockUseViewportMode = vi.mocked(headerModule.useViewportMode);
|
||||
|
||||
const mockCreateSession = vi.fn();
|
||||
const mockSendMessage = vi.fn();
|
||||
|
||||
function buildRoomsMock(overrides: Partial<UseChatRoomsResult> = {}): UseChatRoomsResult {
|
||||
return {
|
||||
rooms: [],
|
||||
roomsLoading: false,
|
||||
roomsError: null,
|
||||
activeRoom: null,
|
||||
activeRoomMembers: [],
|
||||
messages: [],
|
||||
messagesLoading: false,
|
||||
selectRoom: vi.fn(),
|
||||
createRoom: vi.fn().mockResolvedValue({ id: "room-2", name: "product", slug: "product", description: null, projectId: "proj-1", createdBy: null, status: "active", createdAt: "", updatedAt: "" }),
|
||||
deleteRoom: vi.fn().mockResolvedValue(undefined),
|
||||
sendRoomMessage: vi.fn().mockResolvedValue(undefined),
|
||||
refreshRooms: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseViewportMode.mockReturnValue("desktop");
|
||||
mockCreateSession.mockReset();
|
||||
mockSendMessage.mockReset();
|
||||
mockUseChat.mockReturnValue({
|
||||
sessions: [], activeSession: null, sessionsLoading: false, messages: [], messagesLoading: false,
|
||||
isStreaming: false, streamingText: "", streamingThinking: "", streamingToolCalls: [],
|
||||
selectSession: vi.fn(), createSession: mockCreateSession, archiveSession: vi.fn(), deleteSession: vi.fn(),
|
||||
sendMessage: mockSendMessage, stopStreaming: vi.fn(), pendingMessage: "", clearPendingMessage: vi.fn(),
|
||||
loadMoreMessages: vi.fn(), hasMoreMessages: false, searchQuery: "", setSearchQuery: vi.fn(),
|
||||
filteredSessions: [], refreshSessions: vi.fn(), agentsMap: new Map(),
|
||||
} as any);
|
||||
});
|
||||
|
||||
describe("ChatView rooms", () => {
|
||||
it("lists rooms and selects one", async () => {
|
||||
const roomsMock = buildRoomsMock({
|
||||
rooms: [{ id: "room-1", name: "engineering", slug: "engineering", description: null, projectId: "proj-1", createdBy: null, status: "active", createdAt: "", updatedAt: "2026-05-09T00:00:00.000Z" }],
|
||||
activeRoom: { id: "room-1", name: "engineering", slug: "engineering", description: null, projectId: "proj-1", createdBy: null, status: "active", createdAt: "", updatedAt: "2026-05-09T00:00:00.000Z" },
|
||||
messages: [{ id: "msg-1", roomId: "room-1", role: "user", content: "hello", thinkingOutput: null, metadata: null, senderAgentId: null, mentions: [], createdAt: "2026-05-09T00:00:00.000Z" }],
|
||||
});
|
||||
mockUseChatRooms.mockReturnValue(roomsMock);
|
||||
|
||||
render(<ChatView addToast={vi.fn()} projectId="proj-1" experimentalFeatures={{ chatRooms: true }} />);
|
||||
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
|
||||
await userEvent.click(screen.getByTestId("chat-room-item-engineering"));
|
||||
|
||||
expect(roomsMock.selectRoom).toHaveBeenCalledWith("room-1");
|
||||
expect(screen.getByTestId("chat-room-item-engineering")).toBeInTheDocument();
|
||||
expect(screen.getByText("hello")).toBeInTheDocument();
|
||||
describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
|
||||
describe("Mode and navigation", () => {
|
||||
it.todo("Direct/Rooms toggle render exposes both scopes when room mode is enabled");
|
||||
it.todo("room switching loads selected room history without leakage (it.each A↔B matrix)");
|
||||
});
|
||||
|
||||
it("create room modal submits via rooms hook", async () => {
|
||||
const roomsMock = buildRoomsMock({ rooms: [] });
|
||||
mockUseChatRooms.mockReturnValue(roomsMock);
|
||||
|
||||
render(<ChatView addToast={vi.fn()} projectId="proj-1" experimentalFeatures={{ chatRooms: true }} />);
|
||||
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
|
||||
await userEvent.click(screen.getByTestId("chat-create-room-btn"));
|
||||
await userEvent.type(screen.getByLabelText("Room name"), "product");
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Alpha/i }));
|
||||
await userEvent.click(screen.getByRole("dialog", { name: "Create room" }).querySelector(".btn.btn-primary") as HTMLButtonElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(roomsMock.createRoom).toHaveBeenCalledWith({ name: "product", memberAgentIds: ["agent-1"] });
|
||||
});
|
||||
describe("Mention UX in room mode", () => {
|
||||
it.todo("mention popup in room mode prioritizes room members before non-members when filtering");
|
||||
it.todo("non-member mention chip class marks out-of-room mentions in rendered messages");
|
||||
});
|
||||
|
||||
it("mobile selection hides sidebar and supports back button", async () => {
|
||||
mockUseViewportMode.mockReturnValue("mobile");
|
||||
const roomsMock = buildRoomsMock({
|
||||
rooms: [{ id: "room-1", name: "engineering", slug: "engineering", description: null, projectId: "proj-1", createdBy: null, status: "active", createdAt: "", updatedAt: "2026-05-09T00:00:00.000Z" }],
|
||||
activeRoom: { id: "room-1", name: "engineering", slug: "engineering", description: null, projectId: "proj-1", createdBy: null, status: "active", createdAt: "", updatedAt: "2026-05-09T00:00:00.000Z" },
|
||||
});
|
||||
mockUseChatRooms.mockReturnValue(roomsMock);
|
||||
|
||||
render(<ChatView addToast={vi.fn()} projectId="proj-1" experimentalFeatures={{ chatRooms: true }} />);
|
||||
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
|
||||
await userEvent.click(screen.getByTestId("chat-room-item-engineering"));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("sending room message calls hook and clears input", async () => {
|
||||
const roomsMock = buildRoomsMock({
|
||||
activeRoom: { id: "room-1", name: "engineering", slug: "engineering", description: null, projectId: "proj-1", createdBy: null, status: "active", createdAt: "", updatedAt: "2026-05-09T00:00:00.000Z" },
|
||||
});
|
||||
mockUseChatRooms.mockReturnValue(roomsMock);
|
||||
|
||||
render(<ChatView addToast={vi.fn()} projectId="proj-1" experimentalFeatures={{ chatRooms: true }} />);
|
||||
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
|
||||
await userEvent.type(screen.getByTestId("chat-input"), "hello room");
|
||||
await userEvent.click(screen.getByTestId("chat-send-btn"));
|
||||
|
||||
await waitFor(() => expect(roomsMock.sendRoomMessage).toHaveBeenCalledWith("hello room"));
|
||||
await waitFor(() => expect(screen.getByTestId("chat-input")).toHaveValue(""));
|
||||
});
|
||||
|
||||
it("pressing Enter in rooms sends to room and not direct session path", async () => {
|
||||
const roomsMock = buildRoomsMock({
|
||||
activeRoom: { id: "room-1", name: "engineering", slug: "engineering", description: null, projectId: "proj-1", createdBy: null, status: "active", createdAt: "", updatedAt: "2026-05-09T00:00:00.000Z" },
|
||||
});
|
||||
mockUseChatRooms.mockReturnValue(roomsMock);
|
||||
|
||||
render(<ChatView addToast={vi.fn()} projectId="proj-1" experimentalFeatures={{ chatRooms: true }} />);
|
||||
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
|
||||
await userEvent.type(screen.getByTestId("chat-input"), " hello room from enter ");
|
||||
await userEvent.keyboard("{Enter}");
|
||||
|
||||
await waitFor(() => expect(roomsMock.sendRoomMessage).toHaveBeenCalledWith("hello room from enter"));
|
||||
expect(mockCreateSession).not.toHaveBeenCalled();
|
||||
expect(mockSendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens room delete dialog without selecting room", async () => {
|
||||
const roomsMock = buildRoomsMock({
|
||||
rooms: [{ id: "room-1", name: "engineering", slug: "engineering", description: null, projectId: "proj-1", createdBy: null, status: "active", createdAt: "", updatedAt: "2026-05-09T00:00:00.000Z" }],
|
||||
});
|
||||
mockUseChatRooms.mockReturnValue(roomsMock);
|
||||
|
||||
render(<ChatView addToast={vi.fn()} projectId="proj-1" experimentalFeatures={{ chatRooms: true }} />);
|
||||
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
|
||||
await userEvent.click(screen.getByTestId("chat-room-delete-engineering"));
|
||||
|
||||
expect(screen.getByText("Delete Room?")).toBeInTheDocument();
|
||||
expect(roomsMock.selectRoom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("confirms room delete", async () => {
|
||||
const roomsMock = buildRoomsMock({
|
||||
rooms: [{ id: "room-1", name: "engineering", slug: "engineering", description: null, projectId: "proj-1", createdBy: null, status: "active", createdAt: "", updatedAt: "2026-05-09T00:00:00.000Z" }],
|
||||
});
|
||||
mockUseChatRooms.mockReturnValue(roomsMock);
|
||||
|
||||
render(<ChatView addToast={vi.fn()} projectId="proj-1" experimentalFeatures={{ chatRooms: true }} />);
|
||||
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
|
||||
await userEvent.click(screen.getByTestId("chat-room-delete-engineering"));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => expect(roomsMock.deleteRoom).toHaveBeenCalledWith("room-1"));
|
||||
expect(roomsMock.deleteRoom).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cancels room delete without deleting", async () => {
|
||||
const roomsMock = buildRoomsMock({
|
||||
rooms: [{ id: "room-1", name: "engineering", slug: "engineering", description: null, projectId: "proj-1", createdBy: null, status: "active", createdAt: "", updatedAt: "2026-05-09T00:00:00.000Z" }],
|
||||
});
|
||||
mockUseChatRooms.mockReturnValue(roomsMock);
|
||||
|
||||
render(<ChatView addToast={vi.fn()} projectId="proj-1" experimentalFeatures={{ chatRooms: true }} />);
|
||||
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
|
||||
await userEvent.click(screen.getByTestId("chat-room-delete-engineering"));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(screen.queryByText("Delete Room?")).not.toBeInTheDocument();
|
||||
expect(roomsMock.deleteRoom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders newly appended messages from hook updates", async () => {
|
||||
const state = buildRoomsMock({
|
||||
activeRoom: { id: "room-1", name: "engineering", slug: "engineering", description: null, projectId: "proj-1", createdBy: null, status: "active", createdAt: "", updatedAt: "2026-05-09T00:00:00.000Z" },
|
||||
messages: [],
|
||||
});
|
||||
mockUseChatRooms.mockImplementation(() => state);
|
||||
|
||||
const { rerender } = render(<ChatView addToast={vi.fn()} projectId="proj-1" experimentalFeatures={{ chatRooms: true }} />);
|
||||
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
|
||||
|
||||
state.messages = [{ id: "msg-2", roomId: "room-1", role: "assistant", content: "reply", thinkingOutput: null, metadata: null, senderAgentId: "agent-1", mentions: [], createdAt: "2026-05-09T00:00:00.000Z" }];
|
||||
rerender(<ChatView addToast={vi.fn()} projectId="proj-1" experimentalFeatures={{ chatRooms: true }} />);
|
||||
|
||||
expect(screen.getByText("reply")).toBeInTheDocument();
|
||||
describe("Persistence + regression", () => {
|
||||
it.todo("persisted history survives remount and reload in room mode");
|
||||
it.todo("direct-chat parity regression guard keeps direct mode behavior unchanged in the same view");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,11 +16,15 @@ import * as useChatModule from "../../hooks/useChat";
|
||||
import type { UseChatReturn, ChatSessionInfo, ChatMessageInfo, ToolCallInfo } from "../../hooks/useChat";
|
||||
import * as apiModule from "../../api";
|
||||
import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard";
|
||||
import * as useChatRoomsModule from "../../hooks/useChatRooms";
|
||||
import type { UseChatRoomsResult } from "../../hooks/useChatRooms";
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock("../../hooks/useChat");
|
||||
vi.mock("../../hooks/useChatRooms");
|
||||
|
||||
const mockUseChat = vi.mocked(useChatModule.useChat);
|
||||
const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms);
|
||||
const mockFetchDiscoveredSkills = vi.mocked(apiModule.fetchDiscoveredSkills);
|
||||
const mockCreateObjectURL = vi.fn();
|
||||
const mockRevokeObjectURL = vi.fn();
|
||||
@@ -120,6 +124,21 @@ const defaultChatState: UseChatReturn = {
|
||||
agentsMap: new Map(),
|
||||
};
|
||||
|
||||
const defaultRoomsState: UseChatRoomsResult = {
|
||||
rooms: [],
|
||||
roomsLoading: false,
|
||||
roomsError: null,
|
||||
activeRoom: null,
|
||||
activeRoomMembers: [],
|
||||
messages: [],
|
||||
messagesLoading: false,
|
||||
selectRoom: vi.fn(),
|
||||
createRoom: vi.fn(),
|
||||
deleteRoom: vi.fn(),
|
||||
sendRoomMessage: vi.fn(),
|
||||
refreshRooms: vi.fn(),
|
||||
};
|
||||
|
||||
const activeSessionFixture: ChatSessionInfo = {
|
||||
id: "session-001",
|
||||
agentId: "agent-001",
|
||||
@@ -150,6 +169,11 @@ function setupMockChat(overrides: Partial<UseChatReturn> = {}) {
|
||||
mockUseChat.mockReturnValue(state);
|
||||
}
|
||||
|
||||
function setupMockRooms(overrides: Partial<UseChatRoomsResult> = {}) {
|
||||
const state: UseChatRoomsResult = { ...defaultRoomsState, ...overrides };
|
||||
mockUseChatRooms.mockReturnValue(state);
|
||||
}
|
||||
|
||||
function ensureMatchMedia() {
|
||||
if (!window.matchMedia) {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
@@ -177,6 +201,7 @@ function mockViewportMode(mode: "mobile" | "desktop") {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setupMockRooms();
|
||||
mockFetchDiscoveredSkills.mockResolvedValue([]);
|
||||
mockCreateObjectURL.mockImplementation((file: File) => `blob:${file.name}`);
|
||||
Object.defineProperty(URL, "createObjectURL", { value: mockCreateObjectURL, writable: true });
|
||||
@@ -1295,6 +1320,49 @@ describe("ChatView", () => {
|
||||
expect(screen.queryByTestId("agent-mention-popup")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses room member ordering in popup and marks non-member mention chips in room messages", async () => {
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
||||
setupMockRooms({
|
||||
activeRoom: {
|
||||
id: "room-001",
|
||||
slug: "engineering",
|
||||
name: "engineering",
|
||||
createdBy: "agent-001",
|
||||
status: "active",
|
||||
createdAt: "2026-04-08T00:00:00.000Z",
|
||||
updatedAt: "2026-04-08T00:00:00.000Z",
|
||||
},
|
||||
activeRoomMembers: [
|
||||
{ roomId: "room-001", agentId: "agent-001", role: "member", addedAt: "2026-04-08T00:00:00.000Z" },
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
id: "room-msg-1",
|
||||
roomId: "room-001",
|
||||
role: "user",
|
||||
content: "Ping @Beta",
|
||||
senderAgentId: "agent-001",
|
||||
metadata: null,
|
||||
attachments: [],
|
||||
mentions: ["agent-002"],
|
||||
createdAt: "2026-04-08T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
||||
|
||||
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
|
||||
const textarea = screen.getByTestId("chat-input");
|
||||
await userEvent.type(textarea, "@");
|
||||
|
||||
expect(await screen.findByTestId("agent-mention-members-header")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("agent-mention-others-header")).not.toBeInTheDocument();
|
||||
|
||||
const nonMemberChip = screen.getByText("@Beta", { selector: ".chat-mention-chip--non-member" });
|
||||
expect(nonMemberChip).toHaveAttribute("title", "Not a member of engineering");
|
||||
});
|
||||
|
||||
it("renders assistant mentions as plain text in markdown mode", async () => {
|
||||
setupMockChat({
|
||||
activeSession: activeSessionFixture,
|
||||
|
||||
@@ -593,6 +593,32 @@ describe("QuickChatFAB session-first UX", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("renders non-member mention chips when roomContext is provided", async () => {
|
||||
mockFetchChatMessages.mockResolvedValueOnce({
|
||||
messages: [
|
||||
{
|
||||
id: "msg-room-mention",
|
||||
sessionId: "session-model",
|
||||
role: "user",
|
||||
content: "Check with @Agent_Two",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<QuickChatFAB
|
||||
addToast={vi.fn()}
|
||||
projectId="proj-1"
|
||||
roomContext={{ roomName: "engineering", memberIds: new Set(["agent-001"]) }}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
|
||||
const nonMemberChip = await screen.findByText("@Agent_Two", { selector: ".chat-mention-chip--non-member" });
|
||||
expect(nonMemberChip).toHaveAttribute("title", "Not a member of engineering");
|
||||
});
|
||||
|
||||
it("FN-3884: snaps to bottom when switching sessions while open", async () => {
|
||||
mockFetchChatMessages
|
||||
.mockResolvedValueOnce({ messages: [{ id: "msg-1", sessionId: "session-model", role: "assistant", content: "A", createdAt: new Date().toISOString() }] })
|
||||
|
||||
14
packages/dashboard/src/__tests__/chat-routes.rooms.test.ts
Normal file
14
packages/dashboard/src/__tests__/chat-routes.rooms.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
describe("Chat HTTP + SSE routes — rooms (FN-3805..FN-3811 contract)", () => {
|
||||
describe("Room API endpoints", () => {
|
||||
it.todo("room create + list endpoints return created room and include it in subsequent listings");
|
||||
it.todo("per-room history read returns only the selected room timeline");
|
||||
it.todo("send room message with mention records mention data and triggers routed responder behavior");
|
||||
});
|
||||
|
||||
describe("Streaming scope and permissions", () => {
|
||||
it.todo("SSE room channel scoping delivers events only to matching room subscribers (it.each over A↔B subscribers)");
|
||||
it.todo("v1 permissions allow same-project room operations without cross-user 403 checks");
|
||||
});
|
||||
});
|
||||
17
packages/dashboard/src/__tests__/chat.rooms.test.ts
Normal file
17
packages/dashboard/src/__tests__/chat.rooms.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
describe("Chat orchestration — rooms (FN-3805..FN-3811 contract)", () => {
|
||||
describe("Mention routing in rooms", () => {
|
||||
it.todo("direct mention in room routes targeted response from addressed room member");
|
||||
it.todo("non-member mention behavior does not dispatch to out-of-room agents and surfaces explicit feedback");
|
||||
});
|
||||
|
||||
describe("Hybrid dispatch behavior", () => {
|
||||
it.todo("hybrid ambient response includes non-mentioned room members when room dispatch mode allows ambient participation");
|
||||
it.todo("mention suppresses ambient on the addressed agent to avoid duplicate responses");
|
||||
});
|
||||
|
||||
describe("Regression guard", () => {
|
||||
it.todo("direct-chat regression guard keeps legacy direct send path unchanged");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user