Browse Source

Merge pull request #1025 from RooVetGit/cte/app-tabs

Chris Estreich 10 months ago
parent
commit
f3d4c37b2d
2 changed files with 245 additions and 77 deletions
  1. 46 77
      webview-ui/src/App.tsx
  2. 199 0
      webview-ui/src/__tests__/App.test.tsx

+ 46 - 77
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<Record<NonNullable<ExtensionMessage["action"]>, 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<Tab>("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 ? (
+		<WelcomeView />
+	) : (
 		<>
-			{showWelcome ? (
-				<WelcomeView />
-			) : (
-				<>
-					{showSettings && <SettingsView onDone={() => setShowSettings(false)} />}
-					{showHistory && <HistoryView onDone={() => setShowHistory(false)} />}
-					{showMcp && <McpView onDone={() => setShowMcp(false)} />}
-					{showPrompts && <PromptsView onDone={() => 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.) */}
-					<ChatView
-						showHistoryView={() => {
-							setShowSettings(false)
-							setShowMcp(false)
-							setShowPrompts(false)
-							setShowHistory(true)
-						}}
-						isHidden={showSettings || showHistory || showMcp || showPrompts}
-						showAnnouncement={showAnnouncement}
-						hideAnnouncement={() => {
-							setShowAnnouncement(false)
-						}}
-					/>
-				</>
-			)}
+			{tab === "settings" && <SettingsView onDone={() => setTab("chat")} />}
+			{tab === "history" && <HistoryView onDone={() => setTab("chat")} />}
+			{tab === "mcp" && <McpView onDone={() => setTab("chat")} />}
+			{tab === "prompts" && <PromptsView onDone={() => setTab("chat")} />}
+			<ChatView
+				isHidden={tab !== "chat"}
+				showAnnouncement={showAnnouncement}
+				hideAnnouncement={() => setShowAnnouncement(false)}
+				showHistoryView={() => setTab("history")}
+			/>
 		</>
 	)
 }
 
-const App = () => {
-	return (
-		<ExtensionStateContextProvider>
-			<AppContent />
-		</ExtensionStateContextProvider>
-	)
-}
+const AppWithProviders = () => (
+	<ExtensionStateContextProvider>
+		<App />
+	</ExtensionStateContextProvider>
+)
 
-export default App
+export default AppWithProviders

+ 199 - 0
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 (
+			<div data-testid="chat-view" data-hidden={isHidden}>
+				Chat View
+			</div>
+		)
+	},
+}))
+
+jest.mock("../components/settings/SettingsView", () => ({
+	__esModule: true,
+	default: function SettingsView({ onDone }: { onDone: () => void }) {
+		return (
+			<div data-testid="settings-view" onClick={onDone}>
+				Settings View
+			</div>
+		)
+	},
+}))
+
+jest.mock("../components/history/HistoryView", () => ({
+	__esModule: true,
+	default: function HistoryView({ onDone }: { onDone: () => void }) {
+		return (
+			<div data-testid="history-view" onClick={onDone}>
+				History View
+			</div>
+		)
+	},
+}))
+
+jest.mock("../components/mcp/McpView", () => ({
+	__esModule: true,
+	default: function McpView({ onDone }: { onDone: () => void }) {
+		return (
+			<div data-testid="mcp-view" onClick={onDone}>
+				MCP View
+			</div>
+		)
+	},
+}))
+
+jest.mock("../components/prompts/PromptsView", () => ({
+	__esModule: true,
+	default: function PromptsView({ onDone }: { onDone: () => void }) {
+		return (
+			<div data-testid="prompts-view" onClick={onDone}>
+				Prompts View
+			</div>
+		)
+	},
+}))
+
+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(<AppWithProviders />)
+
+		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(<AppWithProviders />)
+
+		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(<AppWithProviders />)
+
+		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(<AppWithProviders />)
+
+		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(<AppWithProviders />)
+
+		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(<AppWithProviders />)
+
+		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(<AppWithProviders />)
+
+		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()
+	})
+})