feat(FN-3899): add chat rooms with send routing and delete-room UI
This merge delivers chat rooms with room send routing and delete-room UI in the dashboard (FN-3899), including fixes for bundled plugin view imports and a release changeset. Supporting changes include a merger improvement that tightens scope-warning diff base when baseBranch is missing, companies.sh Fusion-Task-Id: FN-3899
This commit is contained in:
@@ -109,15 +109,16 @@
|
||||
.chat-room-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-xs);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-room-item:hover {
|
||||
@@ -133,6 +134,13 @@
|
||||
background: color-mix(in srgb, var(--todo) 12%, transparent);
|
||||
}
|
||||
|
||||
.chat-room-item-details {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.chat-room-item-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -142,6 +150,15 @@
|
||||
font-size: var(--space-sm);
|
||||
}
|
||||
|
||||
.chat-room-item-delete {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chat-room-item-delete:hover,
|
||||
.chat-room-item-delete:focus-visible {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.chat-room-thread-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1402,6 +1419,14 @@
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.chat-room-item {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.chat-room-item-delete {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-room-thread-members {
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
@@ -736,6 +736,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
const [messageInput, setMessageInput] = useState("");
|
||||
const [contextMenu, setContextMenu] = useState<{ sessionId: string; x: number; y: number } | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [confirmDeleteRoomId, setConfirmDeleteRoomId] = useState<string | null>(null);
|
||||
const [sidebarVisible, setSidebarVisible] = useState(true);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(CHAT_SIDEBAR_DEFAULT_WIDTH);
|
||||
const [chatScope, setChatScope] = useState<"direct" | "rooms">("direct");
|
||||
@@ -1222,6 +1223,24 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
]);
|
||||
|
||||
|
||||
const handleSendDispatch = useCallback(async () => {
|
||||
const trimmed = messageInput.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (chatScope === "rooms") {
|
||||
if (!rooms.activeRoom) {
|
||||
return;
|
||||
}
|
||||
await rooms.sendRoomMessage(trimmed);
|
||||
setMessageInput("");
|
||||
return;
|
||||
}
|
||||
|
||||
handleSend();
|
||||
}, [messageInput, chatScope, rooms, handleSend]);
|
||||
|
||||
const handleSkillSelect = useCallback(
|
||||
(skill: DiscoveredSkill) => {
|
||||
setMessageInput((currentInput) => {
|
||||
@@ -1376,7 +1395,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void handleSend();
|
||||
void handleSendDispatch();
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -1388,7 +1407,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
filteredSkills,
|
||||
highlightedSkillIndex,
|
||||
handleSkillSelect,
|
||||
handleSend,
|
||||
handleSendDispatch,
|
||||
fileMention,
|
||||
messageInput,
|
||||
],
|
||||
@@ -1857,9 +1876,10 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
const isActive = rooms.activeRoom?.id === room.id;
|
||||
const memberCount = isActive ? rooms.activeRoomMembers.length : "—";
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={room.id}
|
||||
type="button"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`chat-room-item${isActive ? " chat-room-item--active" : ""}`}
|
||||
data-testid={`chat-room-item-${room.slug}`}
|
||||
onClick={() => {
|
||||
@@ -1868,10 +1888,33 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
setSidebarVisible(false);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
rooms.selectRoom(room.id);
|
||||
if (isMobile) {
|
||||
setSidebarVisible(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="chat-room-item-name">#{room.name}</span>
|
||||
<span className="chat-room-item-meta">{memberCount} {memberCount === 1 ? "member" : "members"}</span>
|
||||
</button>
|
||||
<span className="chat-room-item-details">
|
||||
<span className="chat-room-item-name">#{room.name}</span>
|
||||
<span className="chat-room-item-meta">{memberCount} {memberCount === 1 ? "member" : "members"}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon chat-room-item-delete"
|
||||
data-testid={`chat-room-delete-${room.slug}`}
|
||||
aria-label={`Delete room ${room.name}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setConfirmDeleteRoomId(room.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -1956,6 +1999,36 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmDeleteRoomId && (
|
||||
<div className="chat-new-dialog-backdrop chat-view-dialog-backdrop" onClick={() => setConfirmDeleteRoomId(null)}>
|
||||
<div className="chat-new-dialog chat-view-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Delete Room?</h3>
|
||||
<p className="chat-view-delete-dialog-copy">
|
||||
This action cannot be undone. This room and all its messages will be permanently deleted.
|
||||
</p>
|
||||
<div className="chat-new-dialog-actions">
|
||||
<button className="btn btn-sm" onClick={() => setConfirmDeleteRoomId(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
try {
|
||||
await rooms.deleteRoom(confirmDeleteRoomId);
|
||||
setConfirmDeleteRoomId(null);
|
||||
} catch {
|
||||
addToast("Failed to delete room", "error");
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Thread */}
|
||||
{chatScope === "rooms" ? (
|
||||
<div className="chat-thread">
|
||||
@@ -2038,11 +2111,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
type="button"
|
||||
className="chat-input-send"
|
||||
onClick={() => {
|
||||
const trimmed = messageInput.trim();
|
||||
if (!trimmed) return;
|
||||
void rooms.sendRoomMessage(trimmed).then(() => {
|
||||
setMessageInput("");
|
||||
});
|
||||
void handleSendDispatch();
|
||||
}}
|
||||
disabled={!messageInput.trim()}
|
||||
data-testid="chat-send-btn"
|
||||
|
||||
@@ -22,6 +22,9 @@ 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: [],
|
||||
@@ -43,11 +46,13 @@ function buildRoomsMock(overrides: Partial<UseChatRoomsResult> = {}): UseChatRoo
|
||||
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: vi.fn(), archiveSession: vi.fn(), deleteSession: vi.fn(),
|
||||
sendMessage: vi.fn(), stopStreaming: vi.fn(), pendingMessage: "", clearPendingMessage: vi.fn(),
|
||||
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);
|
||||
@@ -117,6 +122,66 @@ describe("ChatView rooms", () => {
|
||||
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" />);
|
||||
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" />);
|
||||
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" />);
|
||||
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" />);
|
||||
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" },
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
import { lazy } from "react";
|
||||
import type { ComponentType } from "react";
|
||||
import { registerPluginView } from "./pluginViewRegistry";
|
||||
|
||||
let registered = false;
|
||||
|
||||
function createMissingPluginView(moduleId: string): ComponentType {
|
||||
return function MissingPluginView() {
|
||||
return `Bundled plugin view unavailable: ${moduleId}`;
|
||||
};
|
||||
}
|
||||
|
||||
async function loadBundledPluginView(moduleId: string, exportName: string) {
|
||||
try {
|
||||
const mod = await import(/* @vite-ignore */ moduleId) as Record<string, ComponentType>;
|
||||
const component = mod[exportName];
|
||||
if (component) {
|
||||
return { default: component };
|
||||
}
|
||||
} catch {
|
||||
// Fall back to placeholder view when optional bundled plugin examples are unavailable.
|
||||
}
|
||||
|
||||
return { default: createMissingPluginView(moduleId) };
|
||||
}
|
||||
|
||||
export function registerBundledPluginViews(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
@@ -10,18 +31,12 @@ export function registerBundledPluginViews(): void {
|
||||
registerPluginView(
|
||||
"fusion-plugin-dependency-graph",
|
||||
"graph",
|
||||
lazy(async () => {
|
||||
const mod = await import("@fusion-plugin-examples/dependency-graph/dashboard-view");
|
||||
return { default: mod.DependencyGraphDashboardView };
|
||||
}),
|
||||
lazy(() => loadBundledPluginView("@fusion-plugin-examples/dependency-graph/dashboard-view", "DependencyGraphDashboardView")),
|
||||
);
|
||||
|
||||
registerPluginView(
|
||||
"roadmap-planner",
|
||||
"roadmaps",
|
||||
lazy(async () => {
|
||||
const mod = await import("@fusion-plugin-examples/roadmap/dashboard-view");
|
||||
return { default: mod.RoadmapDashboardView };
|
||||
}),
|
||||
lazy(() => loadBundledPluginView("@fusion-plugin-examples/roadmap/dashboard-view", "RoadmapDashboardView")),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user