feat(FN-3805): remove dashboard roadmap backend, add reports plugin scaffol

This merge extracts the roadmap feature from the dashboard into a new reports plugin scaffold, adds universal web fetch to reviewer/merger/triage agents, and introduces /tasks deep-link routing. The massive file deletions are roadmap backend code and 2,100+ lines of associated tests being removed as

Fusion-Task-Id: FN-3805
This commit is contained in:
Fusion
2026-05-09 00:15:28 -07:00
committed by gsxdsm
parent e03ccf02c4
commit 365720edb2
3 changed files with 241 additions and 67 deletions

View File

@@ -49,6 +49,51 @@
box-shadow: var(--focus-ring-strong);
}
.chat-sidebar-scope-toggle {
display: flex;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--border);
}
.chat-sidebar-scope-btn {
flex: 1;
padding: var(--space-sm) var(--space-md);
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--text-muted);
font-size: var(--space-md);
transition: background var(--transition-fast), color var(--transition-fast), box-shadow var(--transition-fast);
}
.chat-sidebar-scope-btn:hover {
background: var(--card-hover);
color: var(--text);
}
.chat-sidebar-scope-btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.chat-sidebar-scope-btn--active {
background: var(--card);
color: var(--text);
box-shadow: inset 0 calc(var(--btn-border-width) * -2) 0 var(--todo);
}
.chat-sidebar-rooms-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
color: var(--text-muted);
padding: var(--space-xl) var(--space-lg);
font-size: var(--space-md);
}
.chat-sidebar-search-wrapper {
position: relative;
display: flex;
@@ -1199,6 +1244,10 @@
display: none;
}
.chat-sidebar-scope-btn {
min-height: calc(var(--space-lg) * 2.25);
}
.chat-session-delete-btn {
opacity: 1;
width: calc(var(--space-lg) * 2.25);

View File

@@ -278,6 +278,7 @@ const CHAT_SIDEBAR_DEFAULT_WIDTH = 280;
const CHAT_SIDEBAR_MIN_WIDTH = 180;
const CHAT_SIDEBAR_MAX_WIDTH = 500;
const CHAT_SIDEBAR_STORAGE_KEY = "fusion:chat-sidebar-width";
const CHAT_SCOPE_STORAGE_KEY = "fusion:chat-scope";
interface PendingAttachment {
file: File;
@@ -733,6 +734,12 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [sidebarVisible, setSidebarVisible] = useState(true);
const [sidebarWidth, setSidebarWidth] = useState(CHAT_SIDEBAR_DEFAULT_WIDTH);
/**
* FN-3805: sidebar scope scaffold for Direct vs Rooms tabs.
* Rooms remains placeholder-only here; follow-up tasks FN-3806…FN-3813
* add room data models, APIs, and routing.
*/
const [chatScope, setChatScope] = useState<"direct" | "rooms">("direct");
const [agentsMap, setAgentsMap] = useState<Map<string, Agent>>(new Map());
const [discoveredSkills, setDiscoveredSkills] = useState<DiscoveredSkill[]>([]);
const [skillsLoading, setSkillsLoading] = useState(true);
@@ -801,6 +808,25 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
}
}, []);
useEffect(() => {
try {
const persistedScope = localStorage.getItem(CHAT_SCOPE_STORAGE_KEY);
if (persistedScope === "direct" || persistedScope === "rooms") {
setChatScope(persistedScope);
}
} catch {
// Ignore storage errors.
}
}, []);
useEffect(() => {
try {
localStorage.setItem(CHAT_SCOPE_STORAGE_KEY, chatScope);
} catch {
// Ignore storage errors.
}
}, [chatScope]);
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
enabled: isMobile && !!activeSession,
});
@@ -1671,75 +1697,105 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
className={`chat-sidebar${!sidebarVisible ? " chat-sidebar--hidden" : ""}`}
style={isMobile ? undefined : { width: `${sidebarWidth}px` }}
>
{/* Search section */}
<div className="chat-sidebar-search">
<div className="chat-sidebar-search-wrapper">
<Search size={14} className="chat-sidebar-search-icon" />
<input
type="text"
className="chat-sidebar-search"
placeholder="Search conversations..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
data-testid="chat-search-input"
/>
</div>
<div className="chat-sidebar-scope-toggle" role="tablist" data-testid="chat-sidebar-scope-toggle">
<button
type="button"
role="tab"
className={`chat-sidebar-scope-btn${chatScope === "direct" ? " chat-sidebar-scope-btn--active" : ""}`}
aria-selected={chatScope === "direct"}
data-testid="chat-sidebar-scope-direct"
onClick={() => setChatScope("direct")}
>
Direct
</button>
<button
type="button"
role="tab"
className={`chat-sidebar-scope-btn${chatScope === "rooms" ? " chat-sidebar-scope-btn--active" : ""}`}
aria-selected={chatScope === "rooms"}
data-testid="chat-sidebar-scope-rooms"
onClick={() => setChatScope("rooms")}
>
Rooms
</button>
</div>
{/* Session list section */}
<div className="chat-session-list chat-sidebar-list">
{sessionsLoading ? (
<div style={{ padding: "12px", color: "var(--text-secondary)", fontSize: "13px" }}>
Loading...
</div>
) : filteredSessions.length === 0 ? (
<div style={{ padding: "12px", color: "var(--text-secondary)", fontSize: "13px" }}>
No conversations yet
</div>
) : (
filteredSessions.map((session) => (
<div
key={session.id}
className={`chat-session-item${activeSession?.id === session.id ? " chat-session-item--active" : ""}`}
onClick={() => handleSessionClick(session.id)}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
}}
data-testid={`chat-session-${session.id}`}
>
<button
className="chat-session-delete-btn"
onClick={(e) => {
e.stopPropagation();
setConfirmDelete(session.id);
}}
data-testid="chat-session-delete-btn"
aria-label="Delete conversation"
>
<Trash2 size={14} />
</button>
<div className="chat-session-title">{session.title || "Untitled"}</div>
<div className="chat-session-preview">
{session.lastMessagePreview || "No messages"}
</div>
<div className="chat-session-meta">
<span className="chat-session-meta-model">
{session.modelProvider && (
<ProviderIcon provider={session.modelProvider} size="sm" />
)}
<span>
{agentsMap.get(session.agentId)?.name ||
(session.agentId === FN_AGENT_ID
? (formatModelTag(session.modelProvider, session.modelId) ?? "Fusion")
: session.agentId.slice(0, 30))}
</span>
</span>
<span>{session.updatedAt ? formatRelativeTime(session.updatedAt) : ""}</span>
</div>
{chatScope === "direct" ? (
<>
{/* Search section */}
<div className="chat-sidebar-search">
<div className="chat-sidebar-search-wrapper">
<Search size={14} className="chat-sidebar-search-icon" />
<input
type="text"
className="chat-sidebar-search"
placeholder="Search conversations..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
data-testid="chat-search-input"
/>
</div>
))
)}
</div>
</div>
{/* Session list section */}
<div className="chat-session-list chat-sidebar-list">
{sessionsLoading ? (
<div style={{ padding: "12px", color: "var(--text-secondary)", fontSize: "13px" }}>
Loading...
</div>
) : filteredSessions.length === 0 ? (
<div style={{ padding: "12px", color: "var(--text-secondary)", fontSize: "13px" }}>
No conversations yet
</div>
) : (
filteredSessions.map((session) => (
<div
key={session.id}
className={`chat-session-item${activeSession?.id === session.id ? " chat-session-item--active" : ""}`}
onClick={() => handleSessionClick(session.id)}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
}}
data-testid={`chat-session-${session.id}`}
>
<button
className="chat-session-delete-btn"
onClick={(e) => {
e.stopPropagation();
setConfirmDelete(session.id);
}}
data-testid="chat-session-delete-btn"
aria-label="Delete conversation"
>
<Trash2 size={14} />
</button>
<div className="chat-session-title">{session.title || "Untitled"}</div>
<div className="chat-session-preview">
{session.lastMessagePreview || "No messages"}
</div>
<div className="chat-session-meta">
<span className="chat-session-meta-model">
{session.modelProvider && (
<ProviderIcon provider={session.modelProvider} size="sm" />
)}
<span>
{agentsMap.get(session.agentId)?.name ||
(session.agentId === FN_AGENT_ID
? (formatModelTag(session.modelProvider, session.modelId) ?? "Fusion")
: session.agentId.slice(0, 30))}
</span>
</span>
<span>{session.updatedAt ? formatRelativeTime(session.updatedAt) : ""}</span>
</div>
</div>
))
)}
</div>
</>
) : (
<div className="chat-sidebar-rooms-empty" data-testid="chat-sidebar-rooms-empty">
No rooms yet — room creation lands in a follow-up task.
</div>
)}
{/* Mobile footer with New Chat action */}
<div className="chat-sidebar-footer">
<button

View File

@@ -2431,6 +2431,75 @@ describe("ChatView sidebar structure", () => {
});
});
describe("Direct/Rooms scope toggle", () => {
beforeEach(() => {
localStorage.clear();
});
it("defaults to Direct with sidebar list visible", () => {
setupMockChat({
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByTestId("chat-sidebar-scope-direct")).toHaveAttribute("aria-selected", "true");
expect(screen.getByTestId("chat-sidebar-scope-rooms")).toHaveAttribute("aria-selected", "false");
expect(document.querySelector(".chat-session-list")).toBeInTheDocument();
expect(screen.queryByTestId("chat-sidebar-rooms-empty")).toBeNull();
});
it("shows rooms placeholder and hides direct search/list in Rooms scope", async () => {
setupMockChat({
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
expect(screen.getByTestId("chat-sidebar-scope-rooms")).toHaveAttribute("aria-selected", "true");
expect(screen.getByTestId("chat-sidebar-rooms-empty")).toBeInTheDocument();
expect(document.querySelector(".chat-session-list")).toBeNull();
expect(screen.queryByTestId("chat-search-input")).toBeNull();
});
it("switching back to Direct restores search/list and keeps active session highlight", async () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
await userEvent.click(screen.getByTestId("chat-sidebar-scope-direct"));
expect(screen.getByTestId("chat-search-input")).toBeInTheDocument();
expect(document.querySelector(".chat-session-list")).toBeInTheDocument();
expect(screen.getByTestId("chat-session-session-001")).toHaveClass("chat-session-item--active");
});
it("persists scope in localStorage and restores Rooms on next mount", async () => {
setupMockChat({ sessions: [], filteredSessions: [] });
const { unmount } = render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
expect(localStorage.getItem("fusion:chat-scope")).toBe("rooms");
unmount();
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByTestId("chat-sidebar-scope-rooms")).toHaveAttribute("aria-selected", "true");
expect(screen.getByTestId("chat-sidebar-rooms-empty")).toBeInTheDocument();
});
});
describe("resizable sidebar", () => {
beforeEach(() => {
localStorage.clear();