Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 46 additions & 77 deletions webview-ui/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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" | "research" | "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) {
Expand All @@ -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 changes: 199 additions & 0 deletions webview-ui/src/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading