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:
Fusion
2026-05-09 16:29:11 -07:00
committed by gsxdsm
parent 0db491028e
commit 7ed685a21b
9 changed files with 221 additions and 38 deletions

View File

@@ -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%;
}

View File

@@ -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"

View File

@@ -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" },

View File

@@ -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")),
);
}