diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index ca2aaad75d4..f66385f0dc0 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -1,64 +1,45 @@ import { useCallback, useEffect, useState } from "react" import { useEvent } from "react-use" + import { ExtensionMessage } from "../../src/shared/ExtensionMessage" + +import { vscode } from "./utils/vscode" +import { ExtensionStateContextProvider, useExtensionState } from "./context/ExtensionStateContext" import ChatView from "./components/chat/ChatView" import HistoryView from "./components/history/HistoryView" import SettingsView from "./components/settings/SettingsView" import WelcomeView from "./components/welcome/WelcomeView" -import { ExtensionStateContextProvider, useExtensionState } from "./context/ExtensionStateContext" -import { vscode } from "./utils/vscode" import McpView from "./components/mcp/McpView" import PromptsView from "./components/prompts/PromptsView" -const AppContent = () => { +type Tab = "settings" | "history" | "mcp" | "prompts" | "chat" + +const tabsByMessageAction: Partial, Tab>> = { + chatButtonClicked: "chat", + settingsButtonClicked: "settings", + promptsButtonClicked: "prompts", + mcpButtonClicked: "mcp", + historyButtonClicked: "history", +} + +const App = () => { const { didHydrateState, showWelcome, shouldShowAnnouncement } = useExtensionState() - const [showSettings, setShowSettings] = useState(false) - const [showHistory, setShowHistory] = useState(false) - const [showMcp, setShowMcp] = useState(false) - const [showPrompts, setShowPrompts] = useState(false) const [showAnnouncement, setShowAnnouncement] = useState(false) + const [tab, setTab] = useState("chat") - const handleMessage = useCallback((e: MessageEvent) => { + const onMessage = useCallback((e: MessageEvent) => { const message: ExtensionMessage = e.data - switch (message.type) { - case "action": - switch (message.action!) { - case "settingsButtonClicked": - setShowSettings(true) - setShowHistory(false) - setShowMcp(false) - setShowPrompts(false) - break - case "historyButtonClicked": - setShowSettings(false) - setShowHistory(true) - setShowMcp(false) - setShowPrompts(false) - break - case "mcpButtonClicked": - setShowSettings(false) - setShowHistory(false) - setShowMcp(true) - setShowPrompts(false) - break - case "promptsButtonClicked": - setShowSettings(false) - setShowHistory(false) - setShowMcp(false) - setShowPrompts(true) - break - case "chatButtonClicked": - setShowSettings(false) - setShowHistory(false) - setShowMcp(false) - setShowPrompts(false) - break - } - break + + if (message.type === "action" && message.action) { + const newTab = tabsByMessageAction[message.action] + + if (newTab) { + setTab(newTab) + } } }, []) - useEvent("message", handleMessage) + useEvent("message", onMessage) useEffect(() => { if (shouldShowAnnouncement) { @@ -71,42 +52,30 @@ const AppContent = () => { return null } - return ( + // Do not conditionally load ChatView, it's expensive and there's state we + // don't want to lose (user input, disableInput, askResponse promise, etc.) + return showWelcome ? ( + + ) : ( <> - {showWelcome ? ( - - ) : ( - <> - {showSettings && setShowSettings(false)} />} - {showHistory && setShowHistory(false)} />} - {showMcp && setShowMcp(false)} />} - {showPrompts && setShowPrompts(false)} />} - {/* Do not conditionally load ChatView, it's expensive and there's state we don't want to lose (user input, disableInput, askResponse promise, etc.) */} - { - setShowSettings(false) - setShowMcp(false) - setShowPrompts(false) - setShowHistory(true) - }} - isHidden={showSettings || showHistory || showMcp || showPrompts} - showAnnouncement={showAnnouncement} - hideAnnouncement={() => { - setShowAnnouncement(false) - }} - /> - - )} + {tab === "settings" && setTab("chat")} />} + {tab === "history" && setTab("chat")} />} + {tab === "mcp" && setTab("chat")} />} + {tab === "prompts" && setTab("chat")} />} + setShowAnnouncement(false)} + showHistoryView={() => setTab("history")} + /> ) } -const App = () => { - return ( - - - - ) -} +const AppWithProviders = () => ( + + + +) -export default App +export default AppWithProviders diff --git a/webview-ui/src/__tests__/App.test.tsx b/webview-ui/src/__tests__/App.test.tsx new file mode 100644 index 00000000000..163e745c8c1 --- /dev/null +++ b/webview-ui/src/__tests__/App.test.tsx @@ -0,0 +1,199 @@ +// npx jest src/__tests__/App.test.tsx + +import React from "react" +import { render, screen, act, cleanup } from "@testing-library/react" +import "@testing-library/jest-dom" + +import AppWithProviders from "../App" + +jest.mock("../utils/vscode", () => ({ + vscode: { + postMessage: jest.fn(), + }, +})) + +jest.mock("../components/chat/ChatView", () => ({ + __esModule: true, + default: function ChatView({ isHidden }: { isHidden: boolean }) { + return ( +
+ Chat View +
+ ) + }, +})) + +jest.mock("../components/settings/SettingsView", () => ({ + __esModule: true, + default: function SettingsView({ onDone }: { onDone: () => void }) { + return ( +
+ Settings View +
+ ) + }, +})) + +jest.mock("../components/history/HistoryView", () => ({ + __esModule: true, + default: function HistoryView({ onDone }: { onDone: () => void }) { + return ( +
+ History View +
+ ) + }, +})) + +jest.mock("../components/mcp/McpView", () => ({ + __esModule: true, + default: function McpView({ onDone }: { onDone: () => void }) { + return ( +
+ MCP View +
+ ) + }, +})) + +jest.mock("../components/prompts/PromptsView", () => ({ + __esModule: true, + default: function PromptsView({ onDone }: { onDone: () => void }) { + return ( +
+ Prompts View +
+ ) + }, +})) + +jest.mock("../context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + didHydrateState: true, + showWelcome: false, + shouldShowAnnouncement: false, + }), + ExtensionStateContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +describe("App", () => { + beforeEach(() => { + jest.clearAllMocks() + window.removeEventListener("message", () => {}) + }) + + afterEach(() => { + cleanup() + window.removeEventListener("message", () => {}) + }) + + const triggerMessage = (action: string) => { + const messageEvent = new MessageEvent("message", { + data: { + type: "action", + action, + }, + }) + window.dispatchEvent(messageEvent) + } + + it("shows chat view by default", () => { + render() + + const chatView = screen.getByTestId("chat-view") + expect(chatView).toBeInTheDocument() + expect(chatView.getAttribute("data-hidden")).toBe("false") + }) + + it("switches to settings view when receiving settingsButtonClicked action", async () => { + render() + + act(() => { + triggerMessage("settingsButtonClicked") + }) + + const settingsView = await screen.findByTestId("settings-view") + expect(settingsView).toBeInTheDocument() + + const chatView = screen.getByTestId("chat-view") + expect(chatView.getAttribute("data-hidden")).toBe("true") + }) + + it("switches to history view when receiving historyButtonClicked action", async () => { + render() + + act(() => { + triggerMessage("historyButtonClicked") + }) + + const historyView = await screen.findByTestId("history-view") + expect(historyView).toBeInTheDocument() + + const chatView = screen.getByTestId("chat-view") + expect(chatView.getAttribute("data-hidden")).toBe("true") + }) + + it("switches to MCP view when receiving mcpButtonClicked action", async () => { + render() + + act(() => { + triggerMessage("mcpButtonClicked") + }) + + const mcpView = await screen.findByTestId("mcp-view") + expect(mcpView).toBeInTheDocument() + + const chatView = screen.getByTestId("chat-view") + expect(chatView.getAttribute("data-hidden")).toBe("true") + }) + + it("switches to prompts view when receiving promptsButtonClicked action", async () => { + render() + + act(() => { + triggerMessage("promptsButtonClicked") + }) + + const promptsView = await screen.findByTestId("prompts-view") + expect(promptsView).toBeInTheDocument() + + const chatView = screen.getByTestId("chat-view") + expect(chatView.getAttribute("data-hidden")).toBe("true") + }) + + it("returns to chat view when clicking done in settings view", async () => { + render() + + act(() => { + triggerMessage("settingsButtonClicked") + }) + + const settingsView = await screen.findByTestId("settings-view") + + act(() => { + settingsView.click() + }) + + const chatView = screen.getByTestId("chat-view") + expect(chatView.getAttribute("data-hidden")).toBe("false") + expect(screen.queryByTestId("settings-view")).not.toBeInTheDocument() + }) + + it.each(["history", "mcp", "prompts"])("returns to chat view when clicking done in %s view", async (view) => { + render() + + act(() => { + triggerMessage(`${view}ButtonClicked`) + }) + + const viewElement = await screen.findByTestId(`${view}-view`) + + act(() => { + viewElement.click() + }) + + const chatView = screen.getByTestId("chat-view") + expect(chatView.getAttribute("data-hidden")).toBe("false") + expect(screen.queryByTestId(`${view}-view`)).not.toBeInTheDocument() + }) +})