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:
67
.fusion/tasks/FN-3812/test-plan.md
Normal file
67
.fusion/tasks/FN-3812/test-plan.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# FN-3812 Room Test Plan
|
||||||
|
|
||||||
|
This plan defines contract-neutral coverage for room creation, room switching, persisted history, mention routing, and hybrid room response behavior. Each bullet below maps 1:1 to a single `it.todo(...)` title in the matching scaffold file.
|
||||||
|
|
||||||
|
## Layer 1 — Core chat-store (persistence)
|
||||||
|
|
||||||
|
### Room lifecycle and membership
|
||||||
|
- room creation persists a new room record with creator context and retrievable metadata — creating a room must make it available to later room reads/lists.
|
||||||
|
- member add/remove updates room membership deterministically — adding a member makes them present in membership reads and removing them makes them absent.
|
||||||
|
|
||||||
|
### Room message persistence and retrieval
|
||||||
|
- room-scoped append + list preserves message order and payload fields — appending messages to a room and listing them must return them in stable chronological order with stored content.
|
||||||
|
- cross-room isolation keeps each room history independent — reading room A history must never include messages appended to room B.
|
||||||
|
- room-vs-direct isolation keeps room history separate from direct sessions — room message reads must not surface direct-chat messages, and direct message reads must not surface room messages.
|
||||||
|
|
||||||
|
### Persistence round-trip and metadata fidelity
|
||||||
|
- close/reopen round-trip preserves room, membership, and room history state — reopening the store/database must return the same room data and history without loss.
|
||||||
|
- mention metadata round-trip persists and rehydrates mention routing context — stored mention markers on room messages must be returned unchanged on read.
|
||||||
|
- responder metadata round-trip persists and rehydrates responder attribution — stored responder identity/role markers on assistant room messages must be returned unchanged on read.
|
||||||
|
|
||||||
|
## Layer 2 — Chat orchestration (routing + dispatch)
|
||||||
|
|
||||||
|
### Mention routing in rooms
|
||||||
|
- direct mention in room routes targeted response from addressed room member — mentioning a room member should produce a targeted responder output from that member.
|
||||||
|
- non-member mention behavior does not dispatch to out-of-room agents and surfaces explicit feedback — mentioning an agent outside the room should not trigger that agent and should produce a user-visible non-member notice.
|
||||||
|
|
||||||
|
### Hybrid dispatch behavior
|
||||||
|
- hybrid ambient response includes non-mentioned room members when room dispatch mode allows ambient participation — room messages with mentions can still trigger additional ambient responders.
|
||||||
|
- mention suppresses ambient on the addressed agent to avoid duplicate responses — the directly mentioned agent should respond once, not once direct plus once ambient.
|
||||||
|
|
||||||
|
### Regression guard
|
||||||
|
- direct-chat regression guard keeps legacy direct send path unchanged — non-room chat routing should continue to behave as before room support.
|
||||||
|
|
||||||
|
## Layer 3 — HTTP + SSE
|
||||||
|
|
||||||
|
### Room API endpoints
|
||||||
|
- room create + list endpoints return created room and include it in subsequent listings — API callers can create a room then retrieve it via list/read endpoints.
|
||||||
|
- per-room history read returns only the selected room timeline — room history endpoint must scope results to the requested room.
|
||||||
|
- send room message with mention records mention data and triggers routed responder behavior — room send endpoint must accept mention text and emit resulting room messages.
|
||||||
|
|
||||||
|
### Streaming scope and permissions
|
||||||
|
- SSE room channel scoping delivers events only to matching room subscribers (`it.each` over A↔B subscribers) — a subscriber for room A receives A events and not B events, and vice versa.
|
||||||
|
- v1 permissions allow same-project room operations without cross-user 403 checks — current project-scoped room routes should not reject same-project callers for cross-user constraints.
|
||||||
|
|
||||||
|
## Layer 4 — Dashboard ChatView (UI)
|
||||||
|
|
||||||
|
### Mode and navigation
|
||||||
|
- Direct/Rooms toggle render exposes both scopes when room mode is enabled — users should see and switch between Direct and Rooms modes.
|
||||||
|
- room switching loads selected room history without leakage (`it.each` A↔B matrix) — switching rooms should show only the selected room thread and no carryover from other rooms.
|
||||||
|
|
||||||
|
### Mention UX in room mode
|
||||||
|
- mention popup in room mode prioritizes room members before non-members when filtering — member suggestions appear first for the same query.
|
||||||
|
- non-member mention chip class marks out-of-room mentions in rendered messages — rendered mention chips for non-members should include the non-member styling/state marker.
|
||||||
|
|
||||||
|
### Persistence + regression
|
||||||
|
- persisted history survives remount and reload in room mode — room thread content should still render after component remount/re-init.
|
||||||
|
- direct-chat parity regression guard keeps direct mode behavior unchanged in the same view — existing direct-chat composer/render/send behavior remains intact.
|
||||||
|
|
||||||
|
## Handoff: converting `it.todo` to real assertions
|
||||||
|
|
||||||
|
1. Re-read the merged FN-3805..FN-3811 implementation across core store, orchestration, HTTP/SSE routes, and ChatView to confirm the final shipped contracts.
|
||||||
|
2. Record actual discovered symbols (type names, method names, route paths, SSE event names, prop names, CSS class names) next to each planned assertion before editing test bodies.
|
||||||
|
3. Replace each `it.todo("...")` entry with a concrete `it("...", async () => { ... })` assertion against the merged contract; keep behavior-focused titles but make them implementation-specific where necessary.
|
||||||
|
4. Run targeted suites first (the four rooms scaffold files and adjacent existing room tests), then run `pnpm test` to validate whole-workspace integration.
|
||||||
|
5. Update this plan with any contract surprises or changed assumptions found during conversion so future maintainers can trace why assertions differ from the original stub wording.
|
||||||
|
|
||||||
|
**Do not weaken coverage:** every `it.todo` in these scaffolds must become a real assertion (or a stricter split/consolidation with `it.each` for true matrices). Coverage shrinkage is not acceptable except legitimate matrix consolidation.
|
||||||
@@ -746,6 +746,7 @@ Key server capabilities:
|
|||||||
- `POST /api/chat/rooms/:id/messages/:messageId/attachments` records attachment metadata on an existing room message
|
- `POST /api/chat/rooms/:id/messages/:messageId/attachments` records attachment metadata on an existing room message
|
||||||
- Error contract follows existing API patterns: `400` validation failures, `404` missing resources, `409` duplicate-slug conflicts, `503` when chat store is unavailable
|
- Error contract follows existing API patterns: `400` validation failures, `404` missing resources, `409` duplicate-slug conflicts, `503` when chat store is unavailable
|
||||||
- SSE fan-out on `/api/events` now includes: `chat:room:created`, `chat:room:updated`, `chat:room:deleted`, `chat:room:member:added`, `chat:room:member:removed`, `chat:room:message:added`, `chat:room:message:updated`, `chat:room:message:deleted`
|
- SSE fan-out on `/api/events` now includes: `chat:room:created`, `chat:room:updated`, `chat:room:deleted`, `chat:room:member:added`, `chat:room:member:removed`, `chat:room:message:added`, `chat:room:message:updated`, `chat:room:message:deleted`
|
||||||
|
- **Room test coverage (planned):** FN-3812 tracks the contract-first test matrix for room creation/switching, persisted history, mention routing, and hybrid responder behavior. See `.fusion/tasks/FN-3812/test-plan.md` plus scaffold files: `packages/core/src/__tests__/chat-store.rooms.test.ts`, `packages/dashboard/src/__tests__/chat.rooms.test.ts`, `packages/dashboard/src/__tests__/chat-routes.rooms.test.ts`, and `packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx`.
|
||||||
- **Task log stream**: `/api/tasks/:id/logs/stream` (`server.ts`)
|
- **Task log stream**: `/api/tasks/:id/logs/stream` (`server.ts`)
|
||||||
- SSE endpoint for live task log streaming with project scope resolution
|
- SSE endpoint for live task log streaming with project scope resolution
|
||||||
- **Dev-server stream**: `/api/dev-server/logs/stream` (`dev-server-routes.ts`)
|
- **Dev-server stream**: `/api/dev-server/logs/stream` (`dev-server-routes.ts`)
|
||||||
|
|||||||
20
packages/core/src/__tests__/chat-store.rooms.test.ts
Normal file
20
packages/core/src/__tests__/chat-store.rooms.test.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, it } from "vitest";
|
||||||
|
|
||||||
|
describe("ChatStore — rooms (FN-3805..FN-3811 contract)", () => {
|
||||||
|
describe("Room lifecycle and membership", () => {
|
||||||
|
it.todo("room creation persists a new room record with creator context and retrievable metadata");
|
||||||
|
it.todo("member add/remove updates room membership deterministically");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Room message persistence and retrieval", () => {
|
||||||
|
it.todo("room-scoped append + list preserves message order and payload fields");
|
||||||
|
it.todo("cross-room isolation keeps each room history independent");
|
||||||
|
it.todo("room-vs-direct isolation keeps room history separate from direct sessions");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Persistence round-trip and metadata fidelity", () => {
|
||||||
|
it.todo("close/reopen round-trip preserves room, membership, and room history state");
|
||||||
|
it.todo("mention metadata round-trip persists and rehydrates mention routing context");
|
||||||
|
it.todo("responder metadata round-trip persists and rehydrates responder attribution");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -38,8 +38,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
padding: 6px 12px;
|
padding: calc(var(--space-sm) - (var(--space-xs) / 2)) var(--space-md);
|
||||||
font-size: 13px;
|
font-size: calc(var(--space-sm) + var(--space-xs) * 1.25);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -74,11 +74,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.agent-mention-empty {
|
.agent-mention-empty {
|
||||||
padding: 10px 12px;
|
padding: calc(var(--space-sm) + (var(--space-xs) / 2)) var(--space-md);
|
||||||
font-size: 12px;
|
font-size: var(--space-md);
|
||||||
color: var(--text-dim);
|
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 {
|
.file-mention-popup-loading .spinner {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 16px;
|
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;
|
onSelect: (agent: Agent) => void;
|
||||||
/** Positioning anchor: "above" | "below" the input */
|
/** Positioning anchor: "above" | "below" the input */
|
||||||
position?: "above" | "below";
|
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({
|
export function AgentMentionPopup({
|
||||||
@@ -26,9 +30,24 @@ export function AgentMentionPopup({
|
|||||||
visible,
|
visible,
|
||||||
onSelect,
|
onSelect,
|
||||||
position = "below",
|
position = "below",
|
||||||
|
roomMemberIds,
|
||||||
|
roomName,
|
||||||
}: AgentMentionPopupProps) {
|
}: AgentMentionPopupProps) {
|
||||||
const filteredAgents = useMemo(() => agents.filter((agent) => matchesAgentMentionFilter(agent.name, filter)), [agents, filter]);
|
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) {
|
if (!visible) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -40,25 +59,60 @@ export function AgentMentionPopup({
|
|||||||
role="listbox"
|
role="listbox"
|
||||||
aria-label="Agent mention suggestions"
|
aria-label="Agent mention suggestions"
|
||||||
>
|
>
|
||||||
{filteredAgents.length === 0 ? (
|
{visibleAgents.length === 0 ? (
|
||||||
<div className="agent-mention-empty">No agents found</div>
|
<div className="agent-mention-empty">No agents found</div>
|
||||||
) : (
|
) : (
|
||||||
filteredAgents.map((agent, index) => (
|
<>
|
||||||
<button
|
{roomMode && (
|
||||||
key={agent.id}
|
<div className="agent-mention-section-header" data-testid="agent-mention-members-header">
|
||||||
type="button"
|
{roomName ? `Members of #${roomName}` : "Room members"}
|
||||||
className={`agent-mention-item${index === highlightedIndex ? " agent-mention-item--highlighted" : ""}`}
|
</div>
|
||||||
data-testid={`agent-mention-item-${agent.id}`}
|
)}
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
{memberAgents.map((agent, index) => (
|
||||||
onClick={() => onSelect(agent)}
|
<button
|
||||||
role="option"
|
key={agent.id}
|
||||||
aria-selected={index === highlightedIndex}
|
type="button"
|
||||||
>
|
className={`agent-mention-item${index === highlightedIndex ? " agent-mention-item--highlighted" : ""}`}
|
||||||
<AgentAvatar agent={agent} size={20} />
|
data-testid={`agent-mention-item-${agent.id}`}
|
||||||
<span className="agent-mention-name">{agent.name}</span>
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
<span className="agent-mention-role">{agent.role}</span>
|
onClick={() => onSelect(agent)}
|
||||||
</button>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1438,3 +1438,24 @@
|
|||||||
max-width: 50%;
|
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;
|
type CopyFeedbackState = "success" | "error" | null;
|
||||||
|
|
||||||
|
interface RoomContext {
|
||||||
|
roomId: string;
|
||||||
|
roomName: string;
|
||||||
|
memberIds: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
interface ChatMessageItemProps {
|
interface ChatMessageItemProps {
|
||||||
message: ChatMessageInfo;
|
message: ChatMessageInfo;
|
||||||
/**
|
/**
|
||||||
@@ -572,6 +578,7 @@ interface ChatMessageItemProps {
|
|||||||
activeModelProvider: string | null;
|
activeModelProvider: string | null;
|
||||||
activeSessionId: string | null;
|
activeSessionId: string | null;
|
||||||
mentionAgentsByName: Map<string, Agent>;
|
mentionAgentsByName: Map<string, Agent>;
|
||||||
|
roomContext: RoomContext | null;
|
||||||
copyAction?: ReactNode;
|
copyAction?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,6 +595,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
|||||||
activeModelProvider,
|
activeModelProvider,
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
mentionAgentsByName,
|
mentionAgentsByName,
|
||||||
|
roomContext,
|
||||||
copyAction,
|
copyAction,
|
||||||
}: ChatMessageItemProps) {
|
}: ChatMessageItemProps) {
|
||||||
const isAssistantMessage = message.role === "assistant";
|
const isAssistantMessage = message.role === "assistant";
|
||||||
@@ -606,8 +614,15 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
|||||||
const normalizedName = rawName.replace(/_/g, " ").toLowerCase();
|
const normalizedName = rawName.replace(/_/g, " ").toLowerCase();
|
||||||
const mentionedAgent = mentionAgentsByName.get(normalizedName);
|
const mentionedAgent = mentionAgentsByName.get(normalizedName);
|
||||||
if (mentionedAgent) {
|
if (mentionedAgent) {
|
||||||
|
const isNonMember = Boolean(roomContext && !roomContext.memberIds.has(mentionedAgent.id));
|
||||||
|
const nonMemberLabel = isNonMember ? `Not a member of ${roomContext?.roomName}` : undefined;
|
||||||
parts.push(
|
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, "_")}
|
@{mentionedAgent.name.replace(/\s+/g, "_")}
|
||||||
</span>,
|
</span>,
|
||||||
);
|
);
|
||||||
@@ -619,7 +634,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
|||||||
}
|
}
|
||||||
if (lastIndex < content.length) parts.push(content.slice(lastIndex));
|
if (lastIndex < content.length) parts.push(content.slice(lastIndex));
|
||||||
return parts.length === 0 ? content : parts;
|
return parts.length === 0 ? content : parts;
|
||||||
}, [isAssistantMessage, message.content, mentionAgentsByName]);
|
}, [isAssistantMessage, message.content, mentionAgentsByName, roomContext]);
|
||||||
|
|
||||||
const renderedAttachments = useMemo<ReactNode>(() => {
|
const renderedAttachments = useMemo<ReactNode>(() => {
|
||||||
const attachments = message.attachments;
|
const attachments = message.attachments;
|
||||||
@@ -869,10 +884,31 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
|
|
||||||
const mentionAgents = useMemo(() => Array.from(agentsMap.values()), [agentsMap]);
|
const mentionAgents = useMemo(() => Array.from(agentsMap.values()), [agentsMap]);
|
||||||
|
|
||||||
const filteredMentionAgents = useMemo(
|
const roomContext = useMemo<RoomContext | null>(() => {
|
||||||
() => mentionAgents.filter((agent) => matchesAgentMentionFilter(agent.name, mentionFilter)),
|
if (!chatRoomsEnabled || chatScope !== "rooms" || !rooms.activeRoom) {
|
||||||
[mentionAgents, mentionFilter],
|
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 mentionAgentsByName = useMemo(() => {
|
||||||
const byName = new Map<string, Agent>();
|
const byName = new Map<string, Agent>();
|
||||||
@@ -2103,6 +2139,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
activeModelProvider={null}
|
activeModelProvider={null}
|
||||||
activeSessionId={rooms.activeRoom?.id ?? null}
|
activeSessionId={rooms.activeRoom?.id ?? null}
|
||||||
mentionAgentsByName={mentionAgentsByName}
|
mentionAgentsByName={mentionAgentsByName}
|
||||||
|
roomContext={roomContext}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -2128,6 +2165,16 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
rows={1}
|
rows={1}
|
||||||
data-testid="chat-input"
|
data-testid="chat-input"
|
||||||
/>
|
/>
|
||||||
|
<AgentMentionPopup
|
||||||
|
agents={mentionAgents}
|
||||||
|
filter={mentionFilter}
|
||||||
|
highlightedIndex={mentionHighlightIndex}
|
||||||
|
visible={mentionPopupVisible}
|
||||||
|
onSelect={handleMentionSelect}
|
||||||
|
position="below"
|
||||||
|
roomMemberIds={roomContext?.memberIds}
|
||||||
|
roomName={roomContext?.roomName}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -2204,6 +2251,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
activeModelProvider={activeModelProvider}
|
activeModelProvider={activeModelProvider}
|
||||||
activeSessionId={activeSession?.id ?? null}
|
activeSessionId={activeSession?.id ?? null}
|
||||||
mentionAgentsByName={mentionAgentsByName}
|
mentionAgentsByName={mentionAgentsByName}
|
||||||
|
roomContext={null}
|
||||||
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
|
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -2259,6 +2307,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
activeModelProvider={activeModelProvider}
|
activeModelProvider={activeModelProvider}
|
||||||
activeSessionId={activeSession?.id ?? null}
|
activeSessionId={activeSession?.id ?? null}
|
||||||
mentionAgentsByName={mentionAgentsByName}
|
mentionAgentsByName={mentionAgentsByName}
|
||||||
|
roomContext={null}
|
||||||
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
|
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -2399,6 +2448,8 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
visible={mentionPopupVisible}
|
visible={mentionPopupVisible}
|
||||||
onSelect={handleMentionSelect}
|
onSelect={handleMentionSelect}
|
||||||
position="below"
|
position="below"
|
||||||
|
roomMemberIds={roomContext?.memberIds}
|
||||||
|
roomName={roomContext?.roomName}
|
||||||
/>
|
/>
|
||||||
<FileMentionPopup
|
<FileMentionPopup
|
||||||
visible={fileMention.mentionActive && !mentionPopupVisible}
|
visible={fileMention.mentionActive && !mentionPopupVisible}
|
||||||
|
|||||||
@@ -1206,6 +1206,12 @@
|
|||||||
white-space: nowrap;
|
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 {
|
.quick-chat-panel-message--streaming {
|
||||||
position: relative;
|
position: relative;
|
||||||
opacity: 0.95;
|
opacity: 0.95;
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ interface PendingAttachment {
|
|||||||
previewUrl: string;
|
previewUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface QuickChatRoomContext {
|
||||||
|
roomName: string;
|
||||||
|
memberIds: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
interface QuickChatFABProps {
|
interface QuickChatFABProps {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
addToast: (msg: string, type?: "success" | "error" | "warning") => void;
|
addToast: (msg: string, type?: "success" | "error" | "warning") => void;
|
||||||
@@ -50,6 +55,8 @@ interface QuickChatFABProps {
|
|||||||
onToggleFavorite?: (provider: string) => void;
|
onToggleFavorite?: (provider: string) => void;
|
||||||
/** Called when user toggles a model's favorite status */
|
/** Called when user toggles a model's favorite status */
|
||||||
onToggleModelFavorite?: (modelId: string) => void;
|
onToggleModelFavorite?: (modelId: string) => void;
|
||||||
|
/** Optional room context for member-aware mention UX */
|
||||||
|
roomContext?: QuickChatRoomContext | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParsedModelSelection {
|
interface ParsedModelSelection {
|
||||||
@@ -752,6 +759,7 @@ interface QuickChatMessageItemProps {
|
|||||||
message: ChatMessageInfo;
|
message: ChatMessageInfo;
|
||||||
forcePlain: boolean;
|
forcePlain: boolean;
|
||||||
mentionAgentsByName: Map<string, Agent>;
|
mentionAgentsByName: Map<string, Agent>;
|
||||||
|
roomContext: QuickChatRoomContext | null;
|
||||||
onToggleRender: (id: string) => void;
|
onToggleRender: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -761,6 +769,7 @@ const QuickChatMessageItem = memo(function QuickChatMessageItem({
|
|||||||
message,
|
message,
|
||||||
forcePlain,
|
forcePlain,
|
||||||
mentionAgentsByName,
|
mentionAgentsByName,
|
||||||
|
roomContext,
|
||||||
onToggleRender,
|
onToggleRender,
|
||||||
}: QuickChatMessageItemProps) {
|
}: QuickChatMessageItemProps) {
|
||||||
const isSent = message.role === "user";
|
const isSent = message.role === "user";
|
||||||
@@ -779,8 +788,15 @@ const QuickChatMessageItem = memo(function QuickChatMessageItem({
|
|||||||
const normalizedName = rawName.replace(/_/g, " ").toLowerCase();
|
const normalizedName = rawName.replace(/_/g, " ").toLowerCase();
|
||||||
const mentionedAgent = mentionAgentsByName.get(normalizedName);
|
const mentionedAgent = mentionAgentsByName.get(normalizedName);
|
||||||
if (mentionedAgent) {
|
if (mentionedAgent) {
|
||||||
|
const isNonMember = Boolean(roomContext && !roomContext.memberIds.has(mentionedAgent.id));
|
||||||
|
const nonMemberLabel = isNonMember ? `Not a member of ${roomContext?.roomName}` : undefined;
|
||||||
parts.push(
|
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, "_")}
|
@{mentionedAgent.name.replace(/\s+/g, "_")}
|
||||||
</span>,
|
</span>,
|
||||||
);
|
);
|
||||||
@@ -792,7 +808,7 @@ const QuickChatMessageItem = memo(function QuickChatMessageItem({
|
|||||||
}
|
}
|
||||||
if (lastIndex < content.length) parts.push(content.slice(lastIndex));
|
if (lastIndex < content.length) parts.push(content.slice(lastIndex));
|
||||||
return parts.length === 0 ? content : parts;
|
return parts.length === 0 ? content : parts;
|
||||||
}, [isSent, message.content, mentionAgentsByName]);
|
}, [isSent, message.content, mentionAgentsByName, roomContext]);
|
||||||
|
|
||||||
const assistantBody = useMemo<ReactNode>(() => {
|
const assistantBody = useMemo<ReactNode>(() => {
|
||||||
if (isSent) return null;
|
if (isSent) return null;
|
||||||
@@ -844,6 +860,7 @@ export function QuickChatFAB({
|
|||||||
favoriteModels = [],
|
favoriteModels = [],
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
onToggleModelFavorite,
|
onToggleModelFavorite,
|
||||||
|
roomContext = null,
|
||||||
}: QuickChatFABProps) {
|
}: QuickChatFABProps) {
|
||||||
const { agents } = useAgents(projectId);
|
const { agents } = useAgents(projectId);
|
||||||
// Internal state for uncontrolled mode, controlled state when open prop is provided
|
// Internal state for uncontrolled mode, controlled state when open prop is provided
|
||||||
@@ -1345,10 +1362,20 @@ export function QuickChatFAB({
|
|||||||
return matchingSkills.slice(0, 10);
|
return matchingSkills.slice(0, 10);
|
||||||
}, [discoveredSkills, skillFilter]);
|
}, [discoveredSkills, skillFilter]);
|
||||||
|
|
||||||
const filteredMentionAgents = useMemo(
|
const filteredMentionAgents = useMemo(() => {
|
||||||
() => agents.filter((agent) => matchesAgentMentionFilter(agent.name, mentionFilter)),
|
const matchingAgents = agents.filter((agent) => matchesAgentMentionFilter(agent.name, mentionFilter));
|
||||||
[agents, 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 mentionAgentsByName = useMemo(() => {
|
||||||
const byName = new Map<string, Agent>();
|
const byName = new Map<string, Agent>();
|
||||||
@@ -2349,6 +2376,7 @@ export function QuickChatFAB({
|
|||||||
message={message}
|
message={message}
|
||||||
forcePlain={message.role !== "user" && plainTextMessageIds.has(message.id)}
|
forcePlain={message.role !== "user" && plainTextMessageIds.has(message.id)}
|
||||||
mentionAgentsByName={mentionAgentsByName}
|
mentionAgentsByName={mentionAgentsByName}
|
||||||
|
roomContext={roomContext}
|
||||||
onToggleRender={toggleMessageRenderMode}
|
onToggleRender={toggleMessageRenderMode}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -2402,6 +2430,7 @@ export function QuickChatFAB({
|
|||||||
message={message}
|
message={message}
|
||||||
forcePlain={message.role !== "user" && plainTextMessageIds.has(message.id)}
|
forcePlain={message.role !== "user" && plainTextMessageIds.has(message.id)}
|
||||||
mentionAgentsByName={mentionAgentsByName}
|
mentionAgentsByName={mentionAgentsByName}
|
||||||
|
roomContext={roomContext}
|
||||||
onToggleRender={toggleMessageRenderMode}
|
onToggleRender={toggleMessageRenderMode}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -2593,6 +2622,8 @@ export function QuickChatFAB({
|
|||||||
visible={mentionPopupVisible}
|
visible={mentionPopupVisible}
|
||||||
onSelect={handleMentionSelect}
|
onSelect={handleMentionSelect}
|
||||||
position="above"
|
position="above"
|
||||||
|
roomMemberIds={roomContext?.memberIds}
|
||||||
|
roomName={roomContext?.roomName}
|
||||||
/>
|
/>
|
||||||
<FileMentionPopup
|
<FileMentionPopup
|
||||||
visible={fileMention.mentionActive && !mentionPopupVisible}
|
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",
|
updatedAt: "2026-04-01T00:00:00.000Z",
|
||||||
metadata: {},
|
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", () => {
|
describe("AgentMentionPopup", () => {
|
||||||
@@ -119,6 +128,57 @@ describe("AgentMentionPopup", () => {
|
|||||||
expect(screen.getByText("No agents found")).toBeInTheDocument();
|
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", () => {
|
it("renders nothing when visible is false", () => {
|
||||||
render(
|
render(
|
||||||
<AgentMentionPopup
|
<AgentMentionPopup
|
||||||
|
|||||||
@@ -1,200 +1,18 @@
|
|||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { describe, it } from "vitest";
|
||||||
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";
|
|
||||||
|
|
||||||
vi.mock("../../hooks/useChat", () => ({ useChat: vi.fn() }));
|
describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
|
||||||
vi.mock("../../hooks/useChatRooms", () => ({ useChatRooms: vi.fn() }));
|
describe("Mode and navigation", () => {
|
||||||
vi.mock("../Header", () => ({ useViewportMode: vi.fn() }));
|
it.todo("Direct/Rooms toggle render exposes both scopes when room mode is enabled");
|
||||||
vi.mock("../../api", () => ({
|
it.todo("room switching loads selected room history without leakage (it.each A↔B matrix)");
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("create room modal submits via rooms hook", async () => {
|
describe("Mention UX in room mode", () => {
|
||||||
const roomsMock = buildRoomsMock({ rooms: [] });
|
it.todo("mention popup in room mode prioritizes room members before non-members when filtering");
|
||||||
mockUseChatRooms.mockReturnValue(roomsMock);
|
it.todo("non-member mention chip class marks out-of-room mentions in rendered messages");
|
||||||
|
|
||||||
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"] });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("mobile selection hides sidebar and supports back button", async () => {
|
describe("Persistence + regression", () => {
|
||||||
mockUseViewportMode.mockReturnValue("mobile");
|
it.todo("persisted history survives remount and reload in room mode");
|
||||||
const roomsMock = buildRoomsMock({
|
it.todo("direct-chat parity regression guard keeps direct mode behavior unchanged in the same view");
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,11 +16,15 @@ import * as useChatModule from "../../hooks/useChat";
|
|||||||
import type { UseChatReturn, ChatSessionInfo, ChatMessageInfo, ToolCallInfo } from "../../hooks/useChat";
|
import type { UseChatReturn, ChatSessionInfo, ChatMessageInfo, ToolCallInfo } from "../../hooks/useChat";
|
||||||
import * as apiModule from "../../api";
|
import * as apiModule from "../../api";
|
||||||
import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard";
|
import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard";
|
||||||
|
import * as useChatRoomsModule from "../../hooks/useChatRooms";
|
||||||
|
import type { UseChatRoomsResult } from "../../hooks/useChatRooms";
|
||||||
|
|
||||||
// Mock the hooks
|
// Mock the hooks
|
||||||
vi.mock("../../hooks/useChat");
|
vi.mock("../../hooks/useChat");
|
||||||
|
vi.mock("../../hooks/useChatRooms");
|
||||||
|
|
||||||
const mockUseChat = vi.mocked(useChatModule.useChat);
|
const mockUseChat = vi.mocked(useChatModule.useChat);
|
||||||
|
const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms);
|
||||||
const mockFetchDiscoveredSkills = vi.mocked(apiModule.fetchDiscoveredSkills);
|
const mockFetchDiscoveredSkills = vi.mocked(apiModule.fetchDiscoveredSkills);
|
||||||
const mockCreateObjectURL = vi.fn();
|
const mockCreateObjectURL = vi.fn();
|
||||||
const mockRevokeObjectURL = vi.fn();
|
const mockRevokeObjectURL = vi.fn();
|
||||||
@@ -120,6 +124,21 @@ const defaultChatState: UseChatReturn = {
|
|||||||
agentsMap: new Map(),
|
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 = {
|
const activeSessionFixture: ChatSessionInfo = {
|
||||||
id: "session-001",
|
id: "session-001",
|
||||||
agentId: "agent-001",
|
agentId: "agent-001",
|
||||||
@@ -150,6 +169,11 @@ function setupMockChat(overrides: Partial<UseChatReturn> = {}) {
|
|||||||
mockUseChat.mockReturnValue(state);
|
mockUseChat.mockReturnValue(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupMockRooms(overrides: Partial<UseChatRoomsResult> = {}) {
|
||||||
|
const state: UseChatRoomsResult = { ...defaultRoomsState, ...overrides };
|
||||||
|
mockUseChatRooms.mockReturnValue(state);
|
||||||
|
}
|
||||||
|
|
||||||
function ensureMatchMedia() {
|
function ensureMatchMedia() {
|
||||||
if (!window.matchMedia) {
|
if (!window.matchMedia) {
|
||||||
Object.defineProperty(window, "matchMedia", {
|
Object.defineProperty(window, "matchMedia", {
|
||||||
@@ -177,6 +201,7 @@ function mockViewportMode(mode: "mobile" | "desktop") {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
setupMockRooms();
|
||||||
mockFetchDiscoveredSkills.mockResolvedValue([]);
|
mockFetchDiscoveredSkills.mockResolvedValue([]);
|
||||||
mockCreateObjectURL.mockImplementation((file: File) => `blob:${file.name}`);
|
mockCreateObjectURL.mockImplementation((file: File) => `blob:${file.name}`);
|
||||||
Object.defineProperty(URL, "createObjectURL", { value: mockCreateObjectURL, writable: true });
|
Object.defineProperty(URL, "createObjectURL", { value: mockCreateObjectURL, writable: true });
|
||||||
@@ -1295,6 +1320,49 @@ describe("ChatView", () => {
|
|||||||
expect(screen.queryByTestId("agent-mention-popup")).not.toBeInTheDocument();
|
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 () => {
|
it("renders assistant mentions as plain text in markdown mode", async () => {
|
||||||
setupMockChat({
|
setupMockChat({
|
||||||
activeSession: activeSessionFixture,
|
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 () => {
|
it("FN-3884: snaps to bottom when switching sessions while open", async () => {
|
||||||
mockFetchChatMessages
|
mockFetchChatMessages
|
||||||
.mockResolvedValueOnce({ messages: [{ id: "msg-1", sessionId: "session-model", role: "assistant", content: "A", createdAt: new Date().toISOString() }] })
|
.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