feat(FN-2941): merge fusion/fn-2941

- feat(FN-2941): complete Step 6 — apply review feedback

Fusion-Task-Id: FN-2941
This commit is contained in:
Fusion
2026-04-29 02:09:16 -07:00
committed by gsxdsm
parent e4dee503c5
commit 6ec0b23cb5
4 changed files with 78 additions and 48 deletions

View File

@@ -20,13 +20,6 @@
display: none;
}
.chat-sidebar-header {
padding: 12px;
display: flex;
gap: 8px;
align-items: center;
}
.chat-sidebar-search-wrapper {
position: relative;
display: flex;
@@ -73,7 +66,13 @@
}
.chat-session-item--active {
background: var(--accent-bg, var(--hover));
border-left: calc(var(--btn-border-width) * 3) solid var(--todo);
padding-left: calc(var(--space-md) - (var(--btn-border-width) * 3));
background: color-mix(in srgb, var(--todo) 12%, transparent);
}
.chat-session-item--active:hover {
background: color-mix(in srgb, var(--todo) 16%, transparent);
}
.chat-session-title {
@@ -944,10 +943,18 @@
color: var(--text-muted);
}
/* The mobile-only "New Chat" footer button is hidden on desktop; the
desktop layout uses the .chat-sidebar-header button instead. */
/* Sidebar footer contains the primary New Chat action on desktop and mobile. */
.chat-sidebar-footer {
display: none;
display: block;
padding: var(--space-sm) var(--space-md);
border-top: 1px solid var(--border);
background: var(--bg-secondary);
}
.chat-sidebar-footer .chat-sidebar-footer-btn {
width: 100%;
justify-content: center;
min-height: 40px;
}
/* === Mobile: one pane at a time =========================================
@@ -976,13 +983,7 @@
max-height: calc(var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px))) - var(--header-height));
}
/* On mobile, the "New Chat" affordance moves to a full-width pinned
footer. The desktop top-header button is hidden to free the screen
space for the search field and session list. */
.chat-sidebar-header {
display: none;
}
/* On mobile, the "New Chat" affordance uses a full-width pinned footer. */
.chat-sidebar-footer {
display: block;
padding: var(--space-sm) var(--space-md);

View File

@@ -21,7 +21,7 @@ import {
} from "lucide-react";
import { useChat, type ToolCallInfo } from "../hooks/useChat";
import { useViewportMode } from "./Header";
import { fetchAgents, fetchDiscoveredSkills, fetchModels } from "../api";
import { fetchAgents, fetchDiscoveredSkills, fetchModels, updateGlobalSettings } from "../api";
import type { Agent } from "@fusion/core";
import type { DiscoveredSkill } from "@fusion/dashboard";
import type { ModelInfo } from "../api";
@@ -211,6 +211,8 @@ function NewChatDialog({ projectId, onClose, onCreate }: NewChatDialogProps) {
const [models, setModels] = useState<ModelInfo[]>([]);
const [modelsLoading, setModelsLoading] = useState(true);
const [selectedModel, setSelectedModel] = useState<string>("");
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
// Load agents on mount (project-scoped)
useEffect(() => {
@@ -244,16 +246,52 @@ function NewChatDialog({ projectId, onClose, onCreate }: NewChatDialogProps) {
fetchModels()
.then((response) => {
setModels(response.models);
setFavoriteProviders(response.favoriteProviders);
setFavoriteModels(response.favoriteModels);
})
.catch(() => {
// Silently fail - show empty list
setModels([]);
setFavoriteProviders([]);
setFavoriteModels([]);
})
.finally(() => {
setModelsLoading(false);
});
}, []);
const handleToggleFavorite = useCallback(async (provider: string) => {
const currentFavorites = favoriteProviders;
const isFavorite = currentFavorites.includes(provider);
const newFavorites = isFavorite
? currentFavorites.filter((value) => value !== provider)
: [provider, ...currentFavorites];
setFavoriteProviders(newFavorites);
try {
await updateGlobalSettings({ favoriteProviders: newFavorites, favoriteModels });
} catch {
setFavoriteProviders(currentFavorites);
}
}, [favoriteProviders, favoriteModels]);
const handleToggleModelFavorite = useCallback(async (modelId: string) => {
const currentFavorites = favoriteModels;
const isFavorite = currentFavorites.includes(modelId);
const newFavorites = isFavorite
? currentFavorites.filter((value) => value !== modelId)
: [modelId, ...currentFavorites];
setFavoriteModels(newFavorites);
try {
await updateGlobalSettings({ favoriteProviders, favoriteModels: newFavorites });
} catch {
setFavoriteModels(currentFavorites);
}
}, [favoriteModels, favoriteProviders]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -341,6 +379,10 @@ function NewChatDialog({ projectId, onClose, onCreate }: NewChatDialogProps) {
onChange={setSelectedModel}
label="Model"
placeholder="Select a model"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
/>
)}
</div>
@@ -1139,17 +1181,6 @@ 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"
onClick={() => setShowNewDialog(true)}
data-testid="chat-new-btn"
>
<Plus size={14} />
New Chat
</button>
</div>
{/* Search section */}
<div className="chat-sidebar-search">
<div className="chat-sidebar-search-wrapper">
@@ -1219,7 +1250,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
<button
className="btn btn-sm btn-primary chat-sidebar-footer-btn"
onClick={() => setShowNewDialog(true)}
data-testid="chat-new-btn-mobile"
data-testid="chat-new-btn"
>
<Plus size={14} />
New Chat

View File

@@ -2073,17 +2073,16 @@ describe("ChatView project-scoped agent fetching", () => {
});
describe("ChatView sidebar structure", () => {
it("renders sidebar with explicit section class names", () => {
it("renders sidebar sections without an empty header spacer", () => {
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();
expect(document.querySelector(".chat-sidebar-header")).not.toBeInTheDocument();
});
it("renders desktop header New Chat button", () => {
@@ -2099,7 +2098,7 @@ describe("ChatView sidebar structure", () => {
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByTestId("chat-new-btn-mobile")).toBeInTheDocument();
expect(screen.getByTestId("chat-new-btn")).toBeInTheDocument();
});
it("opens new chat dialog when clicking mobile footer New Chat button", async () => {
@@ -2107,7 +2106,7 @@ describe("ChatView sidebar structure", () => {
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(screen.getByTestId("chat-new-btn-mobile"));
await userEvent.click(screen.getByTestId("chat-new-btn"));
const dialog = document.querySelector(".chat-new-dialog") as HTMLElement | null;
expect(dialog).toBeInTheDocument();

View File

@@ -71,7 +71,7 @@ export interface UseChatReturn {
pendingMessage: string;
// Session operations
selectSession: (id: string) => void;
selectSession: (id: string, sessionOverride?: ChatSessionInfo) => void;
createSession: (
input: { agentId: string; title?: string; modelProvider?: string; modelId?: string },
) => Promise<ChatSessionInfo>;
@@ -243,7 +243,7 @@ export function useChat(projectId?: string): UseChatReturn {
// Restore active session from localStorage after initial load
// Uses a ref to avoid circular dependency with selectSession
const selectSessionRef = useRef<(id: string) => void>(() => {
const selectSessionRef = useRef<(id: string, sessionOverride?: ChatSessionInfo) => void>(() => {
/* noop - will be replaced after selectSession is defined */
});
useEffect(() => {
@@ -284,7 +284,7 @@ export function useChat(projectId?: string): UseChatReturn {
// Select a session
const selectSession = useCallback(
(id: string) => {
(id: string, sessionOverride?: ChatSessionInfo) => {
// Close any existing stream
if (streamRef.current) {
streamRef.current.close();
@@ -292,7 +292,7 @@ export function useChat(projectId?: string): UseChatReturn {
}
// Find and set active session
const session = sessions.find((s) => s.id === id);
const session = sessionOverride ?? sessions.find((s) => s.id === id);
setActiveSession(session || null);
// Reset streaming state
@@ -327,6 +327,11 @@ export function useChat(projectId?: string): UseChatReturn {
const createSession = useCallback(
async (input: { agentId: string; title?: string; modelProvider?: string; modelId?: string }) => {
const data = await apiCreateChatSession(input, projectId);
if (streamRef.current) {
streamRef.current.close();
streamRef.current = null;
}
const newSession: ChatSessionInfo = {
id: data.session.id,
title: data.session.title,
@@ -341,18 +346,12 @@ export function useChat(projectId?: string): UseChatReturn {
// Add to sessions list at the top
setSessions((prev) => [newSession, ...prev]);
// Select the new session
setActiveSession(newSession);
selectSession(newSession.id, newSession);
setMessages([]);
setStreamingText("");
setStreamingThinking("");
setStreamingToolCalls([]);
setIsStreaming(false);
setHasMoreMessages(true);
return newSession;
},
[projectId],
[projectId, selectSession],
);
// Archive a session