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