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

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Change the default `verificationFixRetries` setting from 3 to 2 for new projects and fallback behavior when unset.

View 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.

View File

@@ -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. | | `smartConflictResolution` | `boolean` | `true` | Alias/preferred flag for smart conflict handling. |
| `strictScopeEnforcement` | `boolean` | `false` | Block merges on out-of-scope file changes. | | `strictScopeEnforcement` | `boolean` | `false` | Block merges on out-of-scope file changes. |
| `buildRetryCount` | `number` | `0` | Build retry attempts during merge. | | `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). | | `buildTimeoutMs` | `number` | `300000` | Build timeout in milliseconds (5 minutes). |
| `requirePlanApproval` | `boolean` | `false` | Require manual approval before planning → todo. | | `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. | | `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. |

View File

@@ -109,15 +109,16 @@
.chat-room-item { .chat-room-item {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; align-items: center;
align-items: flex-start; justify-content: space-between;
gap: var(--space-xs); gap: var(--space-sm);
border: none; border: none;
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: transparent; background: transparent;
color: var(--text); color: var(--text);
padding: var(--space-sm) var(--space-md); padding: var(--space-sm) var(--space-md);
text-align: left; text-align: left;
cursor: pointer;
} }
.chat-room-item:hover { .chat-room-item:hover {
@@ -133,6 +134,13 @@
background: color-mix(in srgb, var(--todo) 12%, transparent); 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 { .chat-room-item-name {
font-weight: 500; font-weight: 500;
} }
@@ -142,6 +150,15 @@
font-size: var(--space-sm); 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 { .chat-room-thread-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1402,6 +1419,14 @@
min-width: 36px; min-width: 36px;
} }
.chat-room-item {
padding: var(--space-sm);
}
.chat-room-item-delete {
flex-shrink: 0;
}
.chat-room-thread-members { .chat-room-thread-members {
max-width: 50%; max-width: 50%;
} }

View File

@@ -736,6 +736,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
const [messageInput, setMessageInput] = useState(""); const [messageInput, setMessageInput] = useState("");
const [contextMenu, setContextMenu] = useState<{ sessionId: string; x: number; y: number } | null>(null); const [contextMenu, setContextMenu] = useState<{ sessionId: string; x: number; y: number } | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null); const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [confirmDeleteRoomId, setConfirmDeleteRoomId] = useState<string | null>(null);
const [sidebarVisible, setSidebarVisible] = useState(true); const [sidebarVisible, setSidebarVisible] = useState(true);
const [sidebarWidth, setSidebarWidth] = useState(CHAT_SIDEBAR_DEFAULT_WIDTH); const [sidebarWidth, setSidebarWidth] = useState(CHAT_SIDEBAR_DEFAULT_WIDTH);
const [chatScope, setChatScope] = useState<"direct" | "rooms">("direct"); 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( const handleSkillSelect = useCallback(
(skill: DiscoveredSkill) => { (skill: DiscoveredSkill) => {
setMessageInput((currentInput) => { setMessageInput((currentInput) => {
@@ -1376,7 +1395,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
void handleSend(); void handleSendDispatch();
} }
}, },
[ [
@@ -1388,7 +1407,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
filteredSkills, filteredSkills,
highlightedSkillIndex, highlightedSkillIndex,
handleSkillSelect, handleSkillSelect,
handleSend, handleSendDispatch,
fileMention, fileMention,
messageInput, messageInput,
], ],
@@ -1857,9 +1876,10 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
const isActive = rooms.activeRoom?.id === room.id; const isActive = rooms.activeRoom?.id === room.id;
const memberCount = isActive ? rooms.activeRoomMembers.length : "—"; const memberCount = isActive ? rooms.activeRoomMembers.length : "—";
return ( return (
<button <div
key={room.id} key={room.id}
type="button" role="button"
tabIndex={0}
className={`chat-room-item${isActive ? " chat-room-item--active" : ""}`} className={`chat-room-item${isActive ? " chat-room-item--active" : ""}`}
data-testid={`chat-room-item-${room.slug}`} data-testid={`chat-room-item-${room.slug}`}
onClick={() => { onClick={() => {
@@ -1868,10 +1888,33 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
setSidebarVisible(false); 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-details">
<span className="chat-room-item-meta">{memberCount} {memberCount === 1 ? "member" : "members"}</span> <span className="chat-room-item-name">#{room.name}</span>
</button> <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> </div>
@@ -1956,6 +1999,36 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
</div> </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 */} {/* Thread */}
{chatScope === "rooms" ? ( {chatScope === "rooms" ? (
<div className="chat-thread"> <div className="chat-thread">
@@ -2038,11 +2111,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
type="button" type="button"
className="chat-input-send" className="chat-input-send"
onClick={() => { onClick={() => {
const trimmed = messageInput.trim(); void handleSendDispatch();
if (!trimmed) return;
void rooms.sendRoomMessage(trimmed).then(() => {
setMessageInput("");
});
}} }}
disabled={!messageInput.trim()} disabled={!messageInput.trim()}
data-testid="chat-send-btn" data-testid="chat-send-btn"

View File

@@ -22,6 +22,9 @@ const mockUseChat = vi.mocked(useChatModule.useChat);
const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms); const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms);
const mockUseViewportMode = vi.mocked(headerModule.useViewportMode); const mockUseViewportMode = vi.mocked(headerModule.useViewportMode);
const mockCreateSession = vi.fn();
const mockSendMessage = vi.fn();
function buildRoomsMock(overrides: Partial<UseChatRoomsResult> = {}): UseChatRoomsResult { function buildRoomsMock(overrides: Partial<UseChatRoomsResult> = {}): UseChatRoomsResult {
return { return {
rooms: [], rooms: [],
@@ -43,11 +46,13 @@ function buildRoomsMock(overrides: Partial<UseChatRoomsResult> = {}): UseChatRoo
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockUseViewportMode.mockReturnValue("desktop"); mockUseViewportMode.mockReturnValue("desktop");
mockCreateSession.mockReset();
mockSendMessage.mockReset();
mockUseChat.mockReturnValue({ mockUseChat.mockReturnValue({
sessions: [], activeSession: null, sessionsLoading: false, messages: [], messagesLoading: false, sessions: [], activeSession: null, sessionsLoading: false, messages: [], messagesLoading: false,
isStreaming: false, streamingText: "", streamingThinking: "", streamingToolCalls: [], isStreaming: false, streamingText: "", streamingThinking: "", streamingToolCalls: [],
selectSession: vi.fn(), createSession: vi.fn(), archiveSession: vi.fn(), deleteSession: vi.fn(), selectSession: vi.fn(), createSession: mockCreateSession, archiveSession: vi.fn(), deleteSession: vi.fn(),
sendMessage: vi.fn(), stopStreaming: vi.fn(), pendingMessage: "", clearPendingMessage: vi.fn(), sendMessage: mockSendMessage, stopStreaming: vi.fn(), pendingMessage: "", clearPendingMessage: vi.fn(),
loadMoreMessages: vi.fn(), hasMoreMessages: false, searchQuery: "", setSearchQuery: vi.fn(), loadMoreMessages: vi.fn(), hasMoreMessages: false, searchQuery: "", setSearchQuery: vi.fn(),
filteredSessions: [], refreshSessions: vi.fn(), agentsMap: new Map(), filteredSessions: [], refreshSessions: vi.fn(), agentsMap: new Map(),
} as any); } as any);
@@ -117,6 +122,66 @@ describe("ChatView rooms", () => {
await waitFor(() => expect(screen.getByTestId("chat-input")).toHaveValue("")); 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 () => { it("renders newly appended messages from hook updates", async () => {
const state = buildRoomsMock({ 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" }, 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 { lazy } from "react";
import type { ComponentType } from "react";
import { registerPluginView } from "./pluginViewRegistry"; import { registerPluginView } from "./pluginViewRegistry";
let registered = false; 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 { export function registerBundledPluginViews(): void {
if (registered) return; if (registered) return;
registered = true; registered = true;
@@ -10,18 +31,12 @@ export function registerBundledPluginViews(): void {
registerPluginView( registerPluginView(
"fusion-plugin-dependency-graph", "fusion-plugin-dependency-graph",
"graph", "graph",
lazy(async () => { lazy(() => loadBundledPluginView("@fusion-plugin-examples/dependency-graph/dashboard-view", "DependencyGraphDashboardView")),
const mod = await import("@fusion-plugin-examples/dependency-graph/dashboard-view");
return { default: mod.DependencyGraphDashboardView };
}),
); );
registerPluginView( registerPluginView(
"roadmap-planner", "roadmap-planner",
"roadmaps", "roadmaps",
lazy(async () => { lazy(() => loadBundledPluginView("@fusion-plugin-examples/roadmap/dashboard-view", "RoadmapDashboardView")),
const mod = await import("@fusion-plugin-examples/roadmap/dashboard-view");
return { default: mod.RoadmapDashboardView };
}),
); );
} }

View File

@@ -7618,7 +7618,7 @@ describe("aiMergeTask — in-merge verification fix", () => {
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(4); 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) => { mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd); const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123"); 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({ (store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS, ...DEFAULT_SETTINGS,
testCommand: "vitest run", 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({ await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toMatchObject({
name: "VerificationError", name: "VerificationError",
}); });
// 1 merger AI agent (attempt 1) + 3 fix agent attempts (default) = 4 calls // 1 merger AI agent (attempt 1) + 2 fix agent attempts (default) = 3 calls
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(4); 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 logCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls;
const fixAttempts = logCalls.filter((call: any[]) => const fixAttempts = logCalls.filter((call: any[]) =>
typeof call[1] === "string" && call[1].includes("In-merge verification fix attempt"), 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" // Each attempt produces 2 log entries: "attempt X/2" and "attempt X — verification still fails"
expect(fixAttempts).toHaveLength(6); expect(fixAttempts).toHaveLength(4);
expect(fixAttempts[0][1]).toContain("attempt 1/3"); expect(fixAttempts[0][1]).toContain("attempt 1/2");
expect(fixAttempts[2][1]).toContain("attempt 2/3"); expect(fixAttempts[2][1]).toContain("attempt 2/2");
expect(fixAttempts[4][1]).toContain("attempt 3/3");
}); });
}); });

View File

@@ -5085,7 +5085,7 @@ export async function aiMergeTask(
// Try in-merge fix attempts before propagating // Try in-merge fix attempts before propagating
if (error.name === "VerificationError") { if (error.name === "VerificationError") {
const verificationErr = error as 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)) { if (maxFixRetries > 0 && (verificationErr.verificationResult.testResult || verificationErr.verificationResult.buildResult)) {
mergerLog.log(`${taskId}: deterministic verification failed — attempting in-merge fix (up to ${maxFixRetries} attempts)`); 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 // Check if it's a build verification failure
if (error.message?.includes("Build verification failed")) { 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 // Try in-merge fix before falling back to build retry
if (maxFixRetries > 0 && (effectiveTestCommand || effectiveBuildCommand)) { if (maxFixRetries > 0 && (effectiveTestCommand || effectiveBuildCommand)) {