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:
5
.changeset/default-verification-fix-retries-2.md
Normal file
5
.changeset/default-verification-fix-retries-2.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Change the default `verificationFixRetries` setting from 3 to 2 for new projects and fallback behavior when unset.
|
||||
5
.changeset/fn-3899-chat-rooms-fixes.md
Normal file
5
.changeset/fn-3899-chat-rooms-fixes.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix chat rooms: pressing Enter in a room now posts to the room (previously routed to a 1-on-1 session), and rooms can now be deleted from the rooms sidebar with confirmation.
|
||||
@@ -207,7 +207,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
|
||||
| `smartConflictResolution` | `boolean` | `true` | Alias/preferred flag for smart conflict handling. |
|
||||
| `strictScopeEnforcement` | `boolean` | `false` | Block merges on out-of-scope file changes. |
|
||||
| `buildRetryCount` | `number` | `0` | Build retry attempts during merge. |
|
||||
| `verificationFixRetries` | `number` | `3` | Auto-fix retry attempts when verification fails during merge. |
|
||||
| `verificationFixRetries` | `number` | `2` | Auto-fix retry attempts when verification fails during merge. |
|
||||
| `buildTimeoutMs` | `number` | `300000` | Build timeout in milliseconds (5 minutes). |
|
||||
| `requirePlanApproval` | `boolean` | `false` | Require manual approval before planning → todo. |
|
||||
| `completionDocumentationMode` | `"off" \| "changeset" \| "changelog"` | `"off"` | Controls triage prompt injection for release-note artifacts in future task specs. `"changeset"` requires `.changeset/*.md` workflow guidance; `"changelog"` requires updating an existing changelog file (without inventing a new one); `"off"` disables this automation. |
|
||||
|
||||
@@ -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")),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7618,7 +7618,7 @@ describe("aiMergeTask — in-merge verification fix", () => {
|
||||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("default verificationFixRetries (omitted) results in 3 fix attempts", async () => {
|
||||
it("default verificationFixRetries (omitted) results in 2 fix attempts", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
@@ -7658,26 +7658,25 @@ describe("aiMergeTask — in-merge verification fix", () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
testCommand: "vitest run",
|
||||
// verificationFixRetries is NOT set — should default to 3
|
||||
// verificationFixRetries is NOT set — should default to 2
|
||||
});
|
||||
|
||||
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toMatchObject({
|
||||
name: "VerificationError",
|
||||
});
|
||||
|
||||
// 1 merger AI agent (attempt 1) + 3 fix agent attempts (default) = 4 calls
|
||||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(4);
|
||||
// 1 merger AI agent (attempt 1) + 2 fix agent attempts (default) = 3 calls
|
||||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Verify the log shows 3 fix attempts (2 log entries per attempt: start + failure)
|
||||
// Verify the log shows 2 fix attempts (2 log entries per attempt: start + failure)
|
||||
const logCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const fixAttempts = logCalls.filter((call: any[]) =>
|
||||
typeof call[1] === "string" && call[1].includes("In-merge verification fix attempt"),
|
||||
);
|
||||
// Each attempt produces 2 log entries: "attempt X/3" and "attempt X — verification still fails"
|
||||
expect(fixAttempts).toHaveLength(6);
|
||||
expect(fixAttempts[0][1]).toContain("attempt 1/3");
|
||||
expect(fixAttempts[2][1]).toContain("attempt 2/3");
|
||||
expect(fixAttempts[4][1]).toContain("attempt 3/3");
|
||||
// Each attempt produces 2 log entries: "attempt X/2" and "attempt X — verification still fails"
|
||||
expect(fixAttempts).toHaveLength(4);
|
||||
expect(fixAttempts[0][1]).toContain("attempt 1/2");
|
||||
expect(fixAttempts[2][1]).toContain("attempt 2/2");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5085,7 +5085,7 @@ export async function aiMergeTask(
|
||||
// Try in-merge fix attempts before propagating
|
||||
if (error.name === "VerificationError") {
|
||||
const verificationErr = error as VerificationError;
|
||||
const maxFixRetries = Math.min(settings.verificationFixRetries ?? 3, 3);
|
||||
const maxFixRetries = Math.min(settings.verificationFixRetries ?? 2, 3);
|
||||
|
||||
if (maxFixRetries > 0 && (verificationErr.verificationResult.testResult || verificationErr.verificationResult.buildResult)) {
|
||||
mergerLog.log(`${taskId}: deterministic verification failed — attempting in-merge fix (up to ${maxFixRetries} attempts)`);
|
||||
@@ -5216,7 +5216,7 @@ export async function aiMergeTask(
|
||||
|
||||
// Check if it's a build verification failure
|
||||
if (error.message?.includes("Build verification failed")) {
|
||||
const maxFixRetries = Math.min(settings.verificationFixRetries ?? 3, 3);
|
||||
const maxFixRetries = Math.min(settings.verificationFixRetries ?? 2, 3);
|
||||
|
||||
// Try in-merge fix before falling back to build retry
|
||||
if (maxFixRetries > 0 && (effectiveTestCommand || effectiveBuildCommand)) {
|
||||
|
||||
Reference in New Issue
Block a user