feat(FN-3062): add /clear command to Chat and Quick Chat, fix session banne
The merge adds a `/clear` command to both the Chat view and Quick Chat that clears transient chat state on session resets, with tests for all new hooks and components. It also persists session banner dismissals via a new `useSessionBannerPref` hook, adds a hide-banner setting, and preserves planning Fusion-Task-Id: FN-3062
This commit is contained in:
@@ -979,18 +979,13 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
[createSession, addToast, isMobile],
|
||||
);
|
||||
|
||||
// Handle send message including pending attachment uploads.
|
||||
const handleSend = useCallback(() => {
|
||||
const trimmed = messageInput.trim();
|
||||
const files = pendingAttachments.map((attachment) => attachment.file);
|
||||
if ((!trimmed && files.length === 0) || !activeSession) return;
|
||||
const clearComposerState = useCallback(() => {
|
||||
setMessageInput("");
|
||||
setShowSkillMenu(false);
|
||||
setSkillFilter("");
|
||||
setMentionPopupVisible(false);
|
||||
setMentionFilter("");
|
||||
setMentionStartPos(-1);
|
||||
sendMessage(trimmed, files);
|
||||
setPendingAttachments((prev) => {
|
||||
for (const attachment of prev) {
|
||||
if (attachment.previewUrl) {
|
||||
@@ -999,7 +994,41 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}, [messageInput, pendingAttachments, activeSession, sendMessage]);
|
||||
}, []);
|
||||
|
||||
// Handle send message including pending attachment uploads.
|
||||
const handleSend = useCallback(() => {
|
||||
const trimmed = messageInput.trim();
|
||||
const files = pendingAttachments.map((attachment) => attachment.file);
|
||||
if ((!trimmed && files.length === 0) || !activeSession) return;
|
||||
|
||||
if (trimmed === "/clear") {
|
||||
clearComposerState();
|
||||
stopStreaming();
|
||||
clearPendingMessage();
|
||||
void createSession({
|
||||
agentId: activeSession.agentId,
|
||||
modelProvider: activeSession.modelProvider ?? undefined,
|
||||
modelId: activeSession.modelId ?? undefined,
|
||||
}).catch(() => {
|
||||
addToast("Failed to clear conversation", "error");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
clearComposerState();
|
||||
sendMessage(trimmed, files);
|
||||
}, [
|
||||
messageInput,
|
||||
pendingAttachments,
|
||||
activeSession,
|
||||
clearComposerState,
|
||||
stopStreaming,
|
||||
clearPendingMessage,
|
||||
createSession,
|
||||
addToast,
|
||||
sendMessage,
|
||||
]);
|
||||
|
||||
const focusComposerInput = useCallback(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
@@ -1429,6 +1429,35 @@ export function QuickChatFAB({
|
||||
setMentionFilter("");
|
||||
setMentionStartPos(-1);
|
||||
|
||||
if (trimmed === "/clear") {
|
||||
stopStreaming();
|
||||
clearPendingMessage();
|
||||
attachmentsToSend.forEach((attachment) => {
|
||||
if (attachment.previewUrl) {
|
||||
URL.revokeObjectURL(attachment.previewUrl);
|
||||
}
|
||||
});
|
||||
setPendingAttachments((previous) => previous.filter((attachment) => !attachmentsToSend.includes(attachment)));
|
||||
|
||||
try {
|
||||
if (chatMode === "model") {
|
||||
const parsed = parseModelSelection(resolvedModelSelection);
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
await startFreshSession(FN_AGENT_ID, parsed.modelProvider, parsed.modelId);
|
||||
} else if (selectedAgentId) {
|
||||
await startFreshSession(selectedAgentId);
|
||||
}
|
||||
} catch {
|
||||
addToast("Failed to clear conversation", "error");
|
||||
} finally {
|
||||
focusComposerInput();
|
||||
preserveComposerFocusRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sendMessage(trimmed, attachmentsToSend.map((attachment) => attachment.file));
|
||||
attachmentsToSend.forEach((attachment) => {
|
||||
@@ -1443,7 +1472,19 @@ export function QuickChatFAB({
|
||||
focusComposerInput();
|
||||
preserveComposerFocusRef.current = false;
|
||||
}
|
||||
}, [sendMessage, inputDisabled, messageInput, focusComposerInput]);
|
||||
}, [
|
||||
addToast,
|
||||
chatMode,
|
||||
clearPendingMessage,
|
||||
focusComposerInput,
|
||||
inputDisabled,
|
||||
messageInput,
|
||||
resolvedModelSelection,
|
||||
selectedAgentId,
|
||||
sendMessage,
|
||||
startFreshSession,
|
||||
stopStreaming,
|
||||
]);
|
||||
|
||||
const handleAttachmentDragEnter = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -932,6 +932,51 @@ describe("ChatView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("intercepts exact /clear and starts a fresh session instead of sending message", async () => {
|
||||
const sendMessage = vi.fn();
|
||||
const createSession = vi.fn().mockResolvedValue({ id: "session-new", agentId: "agent-001" });
|
||||
const stopStreaming = vi.fn();
|
||||
const clearPendingMessage = vi.fn();
|
||||
|
||||
setupMockChat({
|
||||
activeSession: activeSessionFixture,
|
||||
messages: [],
|
||||
sendMessage,
|
||||
createSession,
|
||||
stopStreaming,
|
||||
clearPendingMessage,
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const textarea = screen.getByTestId("chat-input");
|
||||
await userEvent.type(textarea, " /clear {enter}");
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
expect(createSession).toHaveBeenCalledWith({ agentId: "agent-001" });
|
||||
expect(stopStreaming).toHaveBeenCalledTimes(1);
|
||||
expect(clearPendingMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not intercept non-exact /clear text", async () => {
|
||||
const sendMessage = vi.fn();
|
||||
const createSession = vi.fn();
|
||||
setupMockChat({
|
||||
activeSession: activeSessionFixture,
|
||||
messages: [],
|
||||
sendMessage,
|
||||
createSession,
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const textarea = screen.getByTestId("chat-input");
|
||||
await userEvent.type(textarea, "/clear now{enter}");
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith("/clear now", []);
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends message on Enter key", async () => {
|
||||
const sendMessage = vi.fn();
|
||||
setupMockChat({
|
||||
|
||||
@@ -24,6 +24,8 @@ const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions);
|
||||
const mockCreateChatSession = vi.mocked(apiModule.createChatSession);
|
||||
const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages);
|
||||
const mockFetchModels = vi.mocked(apiModule.fetchModels);
|
||||
const mockStreamChatResponse = vi.mocked(apiModule.streamChatResponse);
|
||||
const mockCancelChatResponse = vi.mocked(apiModule.cancelChatResponse);
|
||||
const mockUseAgents = vi.mocked(useAgents);
|
||||
|
||||
const agents: Agent[] = [
|
||||
@@ -63,6 +65,11 @@ describe("QuickChatFAB session-first UX", () => {
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
mockFetchChatSessions.mockResolvedValue({ sessions: [modelSession, agentSession] });
|
||||
mockCreateChatSession.mockResolvedValue({ session: { ...modelSession, id: "session-new" } });
|
||||
mockCancelChatResponse.mockResolvedValue({ success: true });
|
||||
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
||||
handlers.onDone?.({ messageId: "msg-stream" });
|
||||
return { close: vi.fn(), isConnected: () => true };
|
||||
});
|
||||
mockFetchModels.mockResolvedValue({
|
||||
models: [{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: true, contextWindow: 128000 }],
|
||||
favoriteProviders: [],
|
||||
@@ -125,6 +132,44 @@ describe("QuickChatFAB session-first UX", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("intercepts exact /clear and starts a fresh session for the active target", async () => {
|
||||
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
|
||||
const input = await screen.findByTestId("quick-chat-input");
|
||||
await waitFor(() => expect(input).not.toBeDisabled());
|
||||
fireEvent.change(input, { target: { value: " /clear " } });
|
||||
fireEvent.click(screen.getByTestId("quick-chat-send"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateChatSession).toHaveBeenCalledWith(
|
||||
{ agentId: "__fn_agent__", modelProvider: "openai", modelId: "gpt-4o" },
|
||||
"proj-1",
|
||||
);
|
||||
});
|
||||
expect(mockStreamChatResponse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not intercept non-exact /clear prompts", async () => {
|
||||
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
|
||||
const input = await screen.findByTestId("quick-chat-input");
|
||||
await waitFor(() => expect(input).not.toBeDisabled());
|
||||
fireEvent.change(input, { target: { value: "/clear now" } });
|
||||
fireEvent.click(screen.getByTestId("quick-chat-send"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStreamChatResponse).toHaveBeenCalledWith(
|
||||
"session-model",
|
||||
"/clear now",
|
||||
expect.any(Object),
|
||||
[],
|
||||
"proj-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("switches existing sessions from dropdown without creating new session", async () => {
|
||||
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
|
||||
@@ -674,6 +674,54 @@ describe("useChat", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("selectSession clears pending queued message state", async () => {
|
||||
const sessionA = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||
const sessionB = makeSession({ id: "session-002", agentId: "agent-002" });
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [sessionA, sessionB] });
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
|
||||
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
||||
|
||||
const { result } = renderHook(() => useChat("proj-123"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(2);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.selectSession("session-001");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeSession?.id).toBe("session-001");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.sendMessage("First");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.sendMessage("Queued follow-up");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.pendingMessage).toBe("Queued follow-up");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.selectSession("session-002");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.pendingMessage).toBe("");
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("clearPendingMessage clears pending message", async () => {
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||
|
||||
@@ -357,6 +357,47 @@ describe("useQuickChat", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("startFreshSession clears queued pending message state", async () => {
|
||||
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
|
||||
const freshSession = makeSession({ id: "session-fresh", agentId: "agent-001" });
|
||||
|
||||
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
|
||||
mockCreateChatSession.mockResolvedValueOnce({ session: freshSession });
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
|
||||
const { result } = renderHook(() => useQuickChat("proj-123"));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchSession("agent-001");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.sendMessage("Hello");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.sendMessage("Queued follow-up");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.pendingMessage).toBe("Queued follow-up");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.startFreshSession();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.pendingMessage).toBe("");
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
expect(result.current.activeSession?.id).toBe("session-fresh");
|
||||
});
|
||||
});
|
||||
|
||||
it("stopStreaming aborts stream and resets streaming state", async () => {
|
||||
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
|
||||
const closeFn = vi.fn();
|
||||
|
||||
@@ -286,6 +286,17 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const resetTransientComposerState = useCallback(() => {
|
||||
cancelStreamingFlushesRef.current?.();
|
||||
cancelStreamingFlushesRef.current = null;
|
||||
pendingMessageRef.current = "";
|
||||
setPendingMessage("");
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setStreamingToolCalls([]);
|
||||
setIsStreaming(false);
|
||||
}, []);
|
||||
|
||||
// Select a session
|
||||
const selectSession = useCallback(
|
||||
(id: string, sessionOverride?: ChatSessionInfo) => {
|
||||
@@ -299,11 +310,8 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
const session = sessionOverride ?? sessions.find((s) => s.id === id);
|
||||
setActiveSession(session || null);
|
||||
|
||||
// Reset streaming state
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setStreamingToolCalls([]);
|
||||
setIsStreaming(false);
|
||||
// Reset transient state
|
||||
resetTransientComposerState();
|
||||
setHasMoreMessages(true);
|
||||
|
||||
// Load messages for this session
|
||||
@@ -320,7 +328,7 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
removeScopedItem(ACTIVE_SESSION_STORAGE_KEY, projectId);
|
||||
}
|
||||
},
|
||||
[sessions, loadMessages, projectId],
|
||||
[sessions, loadMessages, projectId, resetTransientComposerState],
|
||||
);
|
||||
|
||||
// Update the ref to point to the actual selectSession function
|
||||
@@ -352,12 +360,13 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
return [newSession, ...prev];
|
||||
});
|
||||
|
||||
resetTransientComposerState();
|
||||
selectSession(newSession.id, newSession);
|
||||
setMessages([]);
|
||||
|
||||
return newSession;
|
||||
},
|
||||
[projectId, selectSession],
|
||||
[projectId, resetTransientComposerState, selectSession],
|
||||
);
|
||||
|
||||
// Archive a session
|
||||
|
||||
@@ -287,6 +287,17 @@ export function useQuickChat(
|
||||
}
|
||||
}, [activeSession, projectId]);
|
||||
|
||||
const resetTransientComposerState = useCallback(() => {
|
||||
cancelStreamingFlushesRef.current?.();
|
||||
cancelStreamingFlushesRef.current = null;
|
||||
pendingMessageRef.current = "";
|
||||
setPendingMessage("");
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setStreamingToolCalls([]);
|
||||
setIsStreaming(false);
|
||||
}, []);
|
||||
|
||||
// Switch to a different chat target session
|
||||
const switchSession = useCallback(
|
||||
async (agentId: string, modelProvider?: string, modelId?: string) => {
|
||||
@@ -305,11 +316,8 @@ export function useQuickChat(
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
// Reset streaming state
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setStreamingToolCalls([]);
|
||||
setIsStreaming(false);
|
||||
// Reset transient state
|
||||
resetTransientComposerState();
|
||||
}
|
||||
|
||||
if (isSameSession) {
|
||||
@@ -327,7 +335,7 @@ export function useQuickChat(
|
||||
currentSessionKeyRef.current = targetSessionKey;
|
||||
await initializeSession(target.agentId, target.modelProvider, target.modelId);
|
||||
},
|
||||
[initializeSession, reloadMessages, activeSession],
|
||||
[activeSession, initializeSession, reloadMessages, resetTransientComposerState],
|
||||
);
|
||||
|
||||
const selectSession = useCallback(async (session: ChatSession) => {
|
||||
@@ -342,12 +350,9 @@ export function useQuickChat(
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setStreamingToolCalls([]);
|
||||
setIsStreaming(false);
|
||||
resetTransientComposerState();
|
||||
setActiveSession(session);
|
||||
}, []);
|
||||
}, [resetTransientComposerState]);
|
||||
|
||||
const startModelChat = useCallback(
|
||||
async (modelProvider: string, modelId: string) => {
|
||||
@@ -372,10 +377,7 @@ export function useQuickChat(
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setStreamingToolCalls([]);
|
||||
setIsStreaming(false);
|
||||
resetTransientComposerState();
|
||||
setMessages([]);
|
||||
setActiveSession(null);
|
||||
|
||||
@@ -393,7 +395,7 @@ export function useQuickChat(
|
||||
} finally {
|
||||
setSessionsLoading(false);
|
||||
}
|
||||
}, [addToast, createSessionForTarget, projectId]);
|
||||
}, [addToast, createSessionForTarget, projectId, resetTransientComposerState]);
|
||||
|
||||
const stopStreaming = useCallback(() => {
|
||||
if (!activeSession) return;
|
||||
|
||||
Reference in New Issue
Block a user