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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user