feat(FN-2271): optimize chat sidebar layout for mobile and desktop

- Add explicit sidebar section class names in ChatView for header, search, list, and footer layout control
- Add a mobile-only footer New Chat action while keeping the desktop header button to avoid duplicate desktop CTAs
- Update mobile chat sidebar CSS to use full-height layout with hidden header/search and a scrollable session list
- Expand ChatView tests to validate sidebar structure, mobile New Chat behavior, and mobile CSS contract rules
This commit is contained in:
Fusion
2026-04-23 04:28:49 -07:00
committed by gsxdsm
parent 585e48041d
commit a332c8f7dc
6 changed files with 618 additions and 18 deletions

View File

@@ -1017,6 +1017,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
<div className="chat-view">
{/* Sidebar */}
<div className={`chat-sidebar${!sidebarVisible ? " chat-sidebar--hidden" : ""}`}>
{/* Desktop header with New Chat button */}
<div className="chat-sidebar-header">
<button
className="btn btn-sm btn-primary"
@@ -1027,7 +1028,8 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
New Chat
</button>
</div>
<div style={{ padding: "0 12px 8px" }}>
{/* Search section */}
<div className="chat-sidebar-search">
<div className="chat-sidebar-search-wrapper">
<Search size={14} className="chat-sidebar-search-icon" />
<input
@@ -1040,7 +1042,8 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
/>
</div>
</div>
<div className="chat-session-list">
{/* Session list section */}
<div className="chat-session-list chat-sidebar-list">
{sessionsLoading ? (
<div style={{ padding: "12px", color: "var(--text-secondary)", fontSize: "13px" }}>
Loading...
@@ -1089,6 +1092,17 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
))
)}
</div>
{/* Mobile footer with New Chat action */}
<div className="chat-sidebar-footer">
<button
className="btn btn-sm btn-primary chat-sidebar-footer-btn"
onClick={() => setShowNewDialog(true)}
data-testid="chat-new-btn-mobile"
>
<Plus size={14} />
New Chat
</button>
</div>
</div>
{/* Context Menu */}

View File

@@ -26,7 +26,7 @@ import {
RefreshCw,
Trash2,
} from "lucide-react";
import { fetchPiSettings, updatePiSettings, installPiPackage, type PiSettings } from "../api";
import { fetchPiSettings, updatePiSettings, installPiPackage, fetchPiExtensions, updatePiExtensions, type PiSettings, type PiExtensionEntry } from "../api";
import type { ToastType } from "../hooks/useToast";
interface PiExtensionsManagerProps {
@@ -34,6 +34,22 @@ interface PiExtensionsManagerProps {
projectId?: string;
}
/** Map source value to CSS class suffix */
function getSourceClass(source: PiExtensionEntry["source"]): string {
return source.replace(/-/g, "-");
}
/** Get display label for extension source */
function getSourceLabel(source: PiExtensionEntry["source"]): string {
const labels: Record<PiExtensionEntry["source"], string> = {
"fusion-global": "Fusion Global",
"pi-global": "Pi Global",
"fusion-project": "Fusion Project",
"pi-project": "Pi Project",
};
return labels[source] ?? source;
}
/** Determine package source type from the source string */
function getPackageType(source: string): "npm" | "git" | "local" {
if (source.startsWith("npm:")) return "npm";
@@ -46,13 +62,18 @@ function getPackageLabel(source: string): string {
return source.replace(/^(npm:|git:)/, "");
}
export function PiExtensionsManager({ addToast }: PiExtensionsManagerProps) {
export function PiExtensionsManager({ addToast, projectId }: PiExtensionsManagerProps) {
const [settings, setSettings] = useState<PiSettings | null>(null);
const [loading, setLoading] = useState(true);
const [installing, setInstalling] = useState(false);
const [newSource, setNewSource] = useState("");
const [expandedPackages, setExpandedPackages] = useState<Set<number>>(new Set());
// Discovered extensions state
const [extensions, setExtensions] = useState<PiExtensionEntry[]>([]);
const [extensionsLoading, setExtensionsLoading] = useState(true);
const [updatingExtensions, setUpdatingExtensions] = useState(false);
const loadSettings = useCallback(async () => {
try {
setLoading(true);
@@ -65,10 +86,42 @@ export function PiExtensionsManager({ addToast }: PiExtensionsManagerProps) {
}
}, [addToast]);
const loadExtensions = useCallback(async () => {
try {
setExtensionsLoading(true);
const data = await fetchPiExtensions(projectId);
setExtensions(data.extensions);
} catch (err) {
addToast(`Failed to load extensions: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally {
setExtensionsLoading(false);
}
}, [addToast, projectId]);
const handleToggleExtension = useCallback(async (ext: PiExtensionEntry) => {
try {
setUpdatingExtensions(true);
const disabledIds = ext.enabled
? [...extensions.filter((e) => e.enabled && e.id !== ext.id).map((e) => e.id), ext.id]
: extensions.filter((e) => e.enabled && e.id !== ext.id).map((e) => e.id);
await updatePiExtensions(disabledIds, projectId);
await loadExtensions();
addToast(ext.enabled ? "Extension disabled" : "Extension enabled", "success");
} catch (err) {
addToast(`Failed to update extension: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally {
setUpdatingExtensions(false);
}
}, [extensions, projectId, loadExtensions, addToast]);
useEffect(() => {
void loadSettings();
}, [loadSettings]);
useEffect(() => {
void loadExtensions();
}, [loadExtensions]);
const toggleExpanded = (index: number) => {
setExpandedPackages((prev) => {
const next = new Set(prev);
@@ -330,6 +383,60 @@ export function PiExtensionsManager({ addToast }: PiExtensionsManagerProps) {
</div>
</>
)}
{/* Discovered Extensions Section */}
<div className="pi-ext-discovered-section">
<div className="pi-ext-discovered-header">
<h4>Discovered Extensions</h4>
<button
className="btn btn-sm"
onClick={loadExtensions}
disabled={extensionsLoading}
title="Refresh extensions"
>
<RefreshCw size={14} className={extensionsLoading ? "spin" : ""} />
</button>
</div>
<p className="pi-ext-description">
Installed extensions resolved from packages and configured paths.
</p>
{extensionsLoading ? (
<div className="loading-state">Loading extensions</div>
) : extensions.length === 0 ? (
<div className="empty-state">
<Package size={32} className="text-muted" />
<p>No extensions discovered.</p>
</div>
) : (
<div className="pi-ext-list">
{extensions.map((ext) => (
<div key={ext.id} className="pi-ext-item">
<div className="pi-ext-item-content">
<div className="pi-ext-info">
<span className="pi-ext-name">{ext.name}</span>
<span className={`pi-ext-source-badge pi-ext-source-badge--${getSourceClass(ext.source)}`}>
{getSourceLabel(ext.source)}
</span>
</div>
<span className="pi-ext-path">{ext.path}</span>
</div>
<div className="pi-ext-actions">
<label className="toggle-switch">
<input
type="checkbox"
checked={ext.enabled}
onChange={() => void handleToggleExtension(ext)}
disabled={updatingExtensions}
/>
<span className="toggle-slider" />
</label>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -1688,3 +1688,127 @@ describe("ChatView project-scoped agent fetching", () => {
});
});
});
describe("ChatView sidebar structure", () => {
it("renders sidebar with explicit section class names", () => {
setupMockChat({ sessions: [], filteredSessions: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
// Verify explicit sidebar section class names exist
expect(document.querySelector(".chat-sidebar")).toBeInTheDocument();
expect(document.querySelector(".chat-sidebar-header")).toBeInTheDocument();
expect(document.querySelector(".chat-sidebar-search")).toBeInTheDocument();
expect(document.querySelector(".chat-sidebar-list")).toBeInTheDocument();
expect(document.querySelector(".chat-sidebar-footer")).toBeInTheDocument();
});
it("renders desktop header New Chat button", () => {
setupMockChat({ sessions: [], filteredSessions: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByTestId("chat-new-btn")).toBeInTheDocument();
});
it("renders mobile footer New Chat button", () => {
setupMockChat({ sessions: [], filteredSessions: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByTestId("chat-new-btn-mobile")).toBeInTheDocument();
});
it("opens new chat dialog when clicking mobile footer New Chat button", async () => {
setupMockChat({ sessions: [], filteredSessions: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(screen.getByTestId("chat-new-btn-mobile"));
const dialog = document.querySelector(".chat-new-dialog");
expect(dialog).toBeInTheDocument();
});
it("session list has both chat-session-list and chat-sidebar-list classes", () => {
setupMockChat({
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const sessionList = document.querySelector(".chat-session-list");
expect(sessionList).toBeInTheDocument();
expect(sessionList).toHaveClass("chat-sidebar-list");
});
});
describe("ChatView mobile CSS contract", () => {
const css = fs.readFileSync(stylesPath, "utf-8");
// Helper to find a selector rule within any mobile media query block
function findMobileRule(selector: string): string | null {
const mobileRegex = /@media\s*\(max-width:\s*768px\)\s*\{([\s\S]*?)\n\}/g;
let match;
while ((match = mobileRegex.exec(css)) !== null) {
const mediaContent = match[1];
if (mediaContent.includes(selector)) {
const ruleMatch = mediaContent.match(new RegExp(`${selector}\\s*\\{([^}]*)\\}`));
if (ruleMatch) return ruleMatch[1];
}
}
return null;
}
// Helper to check if any mobile media query contains a selector with a specific property
function mobileRuleContains(selector: string, property: string): boolean {
const ruleCSS = findMobileRule(selector);
return ruleCSS !== null && ruleCSS.includes(property);
}
// Helper to check if a selector does NOT contain a property in any mobile media query
function mobileRuleNotContains(selector: string, property: string): boolean {
const mobileRegex = /@media\s*\(max-width:\s*768px\)\s*\{([\s\S]*?)\n\}/g;
let match;
while ((match = mobileRegex.exec(css)) !== null) {
const mediaContent = match[1];
if (mediaContent.includes(selector)) {
const ruleMatch = mediaContent.match(new RegExp(`${selector}\\s*\\{([^}]*)\\}`));
if (ruleMatch && ruleMatch[1].includes(property)) {
return false;
}
}
}
return true;
}
it("mobile .chat-sidebar uses height: 100% instead of max-height: 40vh", () => {
expect(mobileRuleContains(".chat-sidebar", "height: 100%")).toBe(true);
expect(mobileRuleNotContains(".chat-sidebar", "max-height: 40vh")).toBe(true);
});
it("mobile .chat-sidebar-header is hidden", () => {
expect(mobileRuleContains(".chat-sidebar-header", "display: none")).toBe(true);
});
it("mobile .chat-sidebar-search is hidden", () => {
expect(mobileRuleContains(".chat-sidebar-search", "display: none")).toBe(true);
});
it("mobile .chat-sidebar-list has flex: 1 and overflow-y: auto for scrolling", () => {
expect(mobileRuleContains(".chat-sidebar-list", "flex: 1")).toBe(true);
expect(mobileRuleContains(".chat-sidebar-list", "overflow-y: auto")).toBe(true);
expect(mobileRuleContains(".chat-sidebar-list", "min-height: 0")).toBe(true);
});
it("mobile .chat-sidebar-footer exists with display: flex and border-top", () => {
expect(mobileRuleContains(".chat-sidebar-footer", "display: flex")).toBe(true);
expect(mobileRuleContains(".chat-sidebar-footer", "border-top")).toBe(true);
});
it("mobile .chat-sidebar-footer-btn has flex: 1 for full-width button", () => {
expect(mobileRuleContains(".chat-sidebar-footer-btn", "flex: 1")).toBe(true);
expect(mobileRuleContains(".chat-sidebar-footer-btn", "justify-content: center")).toBe(true);
});
});

View File

@@ -7,6 +7,8 @@ import { PiExtensionsManager } from "../PiExtensionsManager";
const mockFetchPiSettings = vi.fn();
const mockUpdatePiSettings = vi.fn();
const mockInstallPiPackage = vi.fn();
const mockFetchPiExtensions = vi.fn();
const mockUpdatePiExtensions = vi.fn();
// Mock lucide-react icons
vi.mock("lucide-react", () => ({
@@ -51,6 +53,8 @@ vi.mock("../../api", () => ({
fetchPiSettings: (...args: unknown[]) => mockFetchPiSettings(...args),
updatePiSettings: (...args: unknown[]) => mockUpdatePiSettings(...args),
installPiPackage: (...args: unknown[]) => mockInstallPiPackage(...args),
fetchPiExtensions: (...args: unknown[]) => mockFetchPiExtensions(...args),
updatePiExtensions: (...args: unknown[]) => mockUpdatePiExtensions(...args),
}));
const mockPiSettings = {
@@ -65,12 +69,27 @@ const mockPiSettings = {
themes: ["/path/to/themes"],
};
const mockExtensions = [
{ id: "ext-1", name: "Example Extension", source: "fusion-global" as const, path: "/path/to/ext-1", enabled: true },
{ id: "ext-2", name: "Another Extension", source: "pi-project" as const, path: "/path/to/ext-2", enabled: false },
{ id: "ext-3", name: "Fusion Project Extension", source: "fusion-project" as const, path: "/path/to/ext-3", enabled: true },
];
const mockExtensionsSettings = {
extensions: mockExtensions,
disabledIds: ["ext-2"],
settingsPath: "/path/to/settings",
};
const addToast = vi.fn();
describe("PiExtensionsManager", () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
// Default mock for fetchPiExtensions to return empty settings
mockFetchPiExtensions.mockResolvedValue({ extensions: [], disabledIds: [], settingsPath: "" });
mockUpdatePiExtensions.mockResolvedValue({ extensions: [], disabledIds: [], settingsPath: "" });
});
describe("Rendering", () => {
@@ -142,8 +161,11 @@ describe("PiExtensionsManager", () => {
mockFetchPiSettings.mockImplementation(() => new Promise(() => {}));
render(<PiExtensionsManager addToast={addToast} />);
const refreshBtn = screen.getByTestId("icon-refresh");
expect(refreshBtn).toHaveClass("spin");
const refreshIcons = screen.getAllByTestId("icon-refresh");
expect(refreshIcons.length).toBeGreaterThan(0);
refreshIcons.forEach((icon) => {
expect(icon).toHaveClass("spin");
});
});
});
@@ -163,7 +185,8 @@ describe("PiExtensionsManager", () => {
render(<PiExtensionsManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByTestId("icon-package")).toBeTruthy();
const emptyState = screen.getByText("No packages configured.").closest(".empty-state");
expect(emptyState?.querySelector('[data-testid="icon-package"]')).toBeTruthy();
});
});
});
@@ -384,4 +407,138 @@ describe("PiExtensionsManager", () => {
});
});
});
describe("Discovered Extensions Section", () => {
it("renders discovered extensions section header", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
mockFetchPiExtensions.mockResolvedValueOnce({ extensions: [], disabledIds: [], settingsPath: "" });
render(<PiExtensionsManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "Discovered Extensions" })).toBeTruthy();
});
});
it("renders extension entries from fetchPiExtensions", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
mockFetchPiExtensions.mockResolvedValueOnce(mockExtensionsSettings);
render(<PiExtensionsManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Example Extension")).toBeTruthy();
expect(screen.getByText("Another Extension")).toBeTruthy();
expect(screen.getByText("Fusion Project Extension")).toBeTruthy();
});
});
it("shows source badges for each extension", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
mockFetchPiExtensions.mockResolvedValueOnce(mockExtensionsSettings);
render(<PiExtensionsManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Fusion Global")).toBeTruthy();
expect(screen.getByText("Pi Project")).toBeTruthy();
expect(screen.getByText("Fusion Project")).toBeTruthy();
});
});
it("displays extension paths", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
mockFetchPiExtensions.mockResolvedValueOnce(mockExtensionsSettings);
render(<PiExtensionsManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("/path/to/ext-1")).toBeTruthy();
expect(screen.getByText("/path/to/ext-2")).toBeTruthy();
expect(screen.getByText("/path/to/ext-3")).toBeTruthy();
});
});
it("renders toggle switches for each extension", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
mockFetchPiExtensions.mockResolvedValueOnce(mockExtensionsSettings);
render(<PiExtensionsManager addToast={addToast} />);
await waitFor(() => {
const toggles = screen.getAllByRole("checkbox");
expect(toggles).toHaveLength(3);
expect(toggles[0]).toBeChecked(); // ext-1 enabled
expect(toggles[1]).not.toBeChecked(); // ext-2 disabled
expect(toggles[2]).toBeChecked(); // ext-3 enabled
});
});
it("coexists with existing global package/path UI", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
mockFetchPiExtensions.mockResolvedValueOnce(mockExtensionsSettings);
render(<PiExtensionsManager addToast={addToast} />);
await waitFor(() => {
// Global package UI
expect(screen.getByText("pi-example")).toBeTruthy();
// Discovered extensions
expect(screen.getByText("Example Extension")).toBeTruthy();
});
});
it("forwards projectId to fetchPiExtensions", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
mockFetchPiExtensions.mockResolvedValueOnce({ extensions: [], disabledIds: [], settingsPath: "" });
render(<PiExtensionsManager addToast={addToast} projectId="test-project-123" />);
await waitFor(() => {
expect(mockFetchPiExtensions).toHaveBeenCalledWith("test-project-123");
});
});
it("toggles extension enabled state", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
mockFetchPiExtensions.mockResolvedValueOnce(mockExtensionsSettings);
mockUpdatePiExtensions.mockResolvedValueOnce(undefined);
render(<PiExtensionsManager addToast={addToast} />);
await waitFor(() => {
const toggles = screen.getAllByRole("checkbox");
expect(toggles[0]).toBeChecked();
});
// Click the first toggle to disable
const toggles = screen.getAllByRole("checkbox");
await userEvent.click(toggles[0]);
await waitFor(() => {
expect(mockUpdatePiExtensions).toHaveBeenCalled();
});
});
it("shows error toast when fetch extensions fails", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
mockFetchPiExtensions.mockRejectedValueOnce(new Error("Failed to load"));
render(<PiExtensionsManager addToast={addToast} />);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Failed to load extensions: Failed to load", "error");
});
});
it("shows empty state when no extensions found", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
mockFetchPiExtensions.mockResolvedValueOnce({ extensions: [], disabledIds: [], settingsPath: "" });
render(<PiExtensionsManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("No extensions discovered.")).toBeTruthy();
});
});
});
});