Skip to content

Commit 4a5684a

Browse files
committed
add refresh button and update coverage
1 parent a3683a5 commit 4a5684a

2 files changed

Lines changed: 215 additions & 2 deletions

File tree

‎webview-ui/src/components/settings/providers/OpenCodeGo.tsx‎

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
1-
import { useCallback } from "react"
1+
import { useCallback, useState, useEffect, useRef } from "react"
22
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
33

44
import {
55
type ProviderSettings,
66
type OrganizationAllowList,
77
type RouterModels,
8+
type ExtensionMessage,
89
opencodeGoDefaultModelId,
910
} from "@roo-code/types"
1011

12+
import type { RouterName } from "@roo/api"
13+
14+
import { vscode } from "@src/utils/vscode"
1115
import { useAppTranslation } from "@src/i18n/TranslationContext"
1216
import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink"
17+
import { Button } from "@src/components/ui"
1318

1419
import { inputEventTransform } from "../transforms"
1520
import { ModelPicker } from "../ModelPicker"
@@ -32,6 +37,34 @@ export const OpenCodeGo = ({
3237
simplifySettings,
3338
}: OpenCodeGoProps) => {
3439
const { t } = useAppTranslation()
40+
const [refreshStatus, setRefreshStatus] = useState<"idle" | "loading" | "success" | "error">("idle")
41+
const [refreshError, setRefreshError] = useState<string | undefined>()
42+
const errorJustReceived = useRef(false)
43+
44+
useEffect(() => {
45+
const handleMessage = (event: MessageEvent<ExtensionMessage>) => {
46+
const message = event.data
47+
if (message.type === "singleRouterModelFetchResponse" && !message.success) {
48+
const providerName = message.values?.provider as RouterName
49+
if (providerName === "opencode-go") {
50+
errorJustReceived.current = true
51+
setRefreshStatus("error")
52+
setRefreshError(message.error)
53+
}
54+
} else if (message.type === "routerModels") {
55+
if (refreshStatus === "loading") {
56+
if (!errorJustReceived.current) {
57+
setRefreshStatus("success")
58+
}
59+
}
60+
}
61+
}
62+
63+
window.addEventListener("message", handleMessage)
64+
return () => {
65+
window.removeEventListener("message", handleMessage)
66+
}
67+
}, [refreshStatus])
3568

3669
const handleInputChange = useCallback(
3770
<K extends keyof ProviderSettings, E>(
@@ -44,6 +77,16 @@ export const OpenCodeGo = ({
4477
[setApiConfigurationField],
4578
)
4679

80+
const handleRefreshModels = useCallback(() => {
81+
errorJustReceived.current = false
82+
setRefreshStatus("loading")
83+
setRefreshError(undefined)
84+
vscode.postMessage({
85+
type: "requestRouterModels",
86+
values: { provider: "opencode-go", refresh: true, opencodeGoApiKey: apiConfiguration.opencodeGoApiKey },
87+
})
88+
}, [apiConfiguration.opencodeGoApiKey])
89+
4790
return (
4891
<>
4992
<VSCodeTextField
@@ -62,6 +105,33 @@ export const OpenCodeGo = ({
62105
{t("settings:providers.getOpencodeGoApiKey")}
63106
</VSCodeButtonLink>
64107
)}
108+
<Button
109+
variant="outline"
110+
onClick={handleRefreshModels}
111+
disabled={refreshStatus === "loading"}
112+
className="w-full">
113+
<div className="flex items-center gap-2">
114+
{refreshStatus === "loading" ? (
115+
<span className="codicon codicon-loading codicon-modifier-spin" />
116+
) : (
117+
<span className="codicon codicon-refresh" />
118+
)}
119+
{t("settings:providers.refreshModels.label")}
120+
</div>
121+
</Button>
122+
{refreshStatus === "loading" && (
123+
<div className="text-sm text-vscode-descriptionForeground">
124+
{t("settings:providers.refreshModels.loading")}
125+
</div>
126+
)}
127+
{refreshStatus === "success" && (
128+
<div className="text-sm text-vscode-foreground">{t("settings:providers.refreshModels.success")}</div>
129+
)}
130+
{refreshStatus === "error" && (
131+
<div className="text-sm text-vscode-errorForeground">
132+
{refreshError || t("settings:providers.refreshModels.error")}
133+
</div>
134+
)}
65135
<ModelPicker
66136
apiConfiguration={apiConfiguration}
67137
setApiConfigurationField={setApiConfigurationField}

‎webview-ui/src/components/settings/providers/__tests__/OpenCodeGo.spec.tsx‎

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { render, screen, fireEvent } from "@testing-library/react"
1+
import { render, screen, fireEvent, act } from "@testing-library/react"
22

33
import type { ProviderSettings, OrganizationAllowList } from "@roo-code/types"
44
import { opencodeGoDefaultModelId } from "@roo-code/types"
@@ -38,6 +38,25 @@ vi.mock("../../ModelPicker", () => ({
3838
),
3939
}))
4040

41+
const { postMessageMock } = vi.hoisted(() => ({
42+
postMessageMock: vi.fn(),
43+
}))
44+
45+
vi.mock("@src/utils/vscode", () => ({
46+
vscode: {
47+
postMessage: postMessageMock,
48+
},
49+
}))
50+
51+
// Stub the shared Button so we can assert onClick/disabled without its styling deps.
52+
vi.mock("@src/components/ui", () => ({
53+
Button: ({ children, onClick, disabled, className }: any) => (
54+
<button onClick={onClick} disabled={disabled} className={className} data-testid="refresh-button">
55+
{children}
56+
</button>
57+
),
58+
}))
59+
4160
describe("OpenCodeGo", () => {
4261
const organizationAllowList: OrganizationAllowList = { allowAll: true, providers: {} }
4362
const mockSetApiConfigurationField = vi.fn()
@@ -87,4 +106,128 @@ describe("OpenCodeGo", () => {
87106
expect(picker).toHaveAttribute("data-model-id-key", "opencodeGoModelId")
88107
expect(picker).toHaveAttribute("data-service-name", "Opencode Go")
89108
})
109+
110+
describe("refresh models", () => {
111+
const dispatchMessage = (data: any) =>
112+
act(() => {
113+
window.dispatchEvent(new MessageEvent("message", { data }))
114+
})
115+
116+
it("renders the refresh button in idle state", () => {
117+
renderComponent({ opencodeGoApiKey: "key" })
118+
119+
const button = screen.getByTestId("refresh-button")
120+
expect(button).not.toBeDisabled()
121+
expect(button.querySelector(".codicon-refresh")).not.toBeNull()
122+
expect(screen.getByText("settings:providers.refreshModels.label")).toBeInTheDocument()
123+
})
124+
125+
it("sends requestRouterModels with the current api key when clicked", () => {
126+
renderComponent({ opencodeGoApiKey: "my-key" })
127+
128+
fireEvent.click(screen.getByTestId("refresh-button"))
129+
130+
expect(postMessageMock).toHaveBeenCalledWith({
131+
type: "requestRouterModels",
132+
values: { provider: "opencode-go", refresh: true, opencodeGoApiKey: "my-key" },
133+
})
134+
})
135+
136+
it("enters loading state and disables the button while refreshing", () => {
137+
renderComponent({ opencodeGoApiKey: "key" })
138+
139+
fireEvent.click(screen.getByTestId("refresh-button"))
140+
141+
const button = screen.getByTestId("refresh-button")
142+
expect(button).toBeDisabled()
143+
expect(button.querySelector(".codicon-loading")).not.toBeNull()
144+
expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument()
145+
})
146+
147+
it("shows success state when routerModels arrives while loading", () => {
148+
renderComponent({ opencodeGoApiKey: "key" })
149+
150+
fireEvent.click(screen.getByTestId("refresh-button"))
151+
dispatchMessage({ type: "routerModels" })
152+
153+
expect(screen.getByText("settings:providers.refreshModels.success")).toBeInTheDocument()
154+
})
155+
156+
it("shows error state with the received error message on fetch failure", () => {
157+
renderComponent({ opencodeGoApiKey: "key" })
158+
159+
fireEvent.click(screen.getByTestId("refresh-button"))
160+
dispatchMessage({
161+
type: "singleRouterModelFetchResponse",
162+
success: false,
163+
values: { provider: "opencode-go" },
164+
error: "Invalid API key",
165+
})
166+
167+
expect(screen.getByText("Invalid API key")).toBeInTheDocument()
168+
})
169+
170+
it("falls back to the default error translation when no error is provided", () => {
171+
renderComponent({ opencodeGoApiKey: "key" })
172+
173+
fireEvent.click(screen.getByTestId("refresh-button"))
174+
dispatchMessage({
175+
type: "singleRouterModelFetchResponse",
176+
success: false,
177+
values: { provider: "opencode-go" },
178+
})
179+
180+
expect(screen.getByText("settings:providers.refreshModels.error")).toBeInTheDocument()
181+
})
182+
183+
it("ignores fetch failures for other providers", () => {
184+
renderComponent({ opencodeGoApiKey: "key" })
185+
186+
fireEvent.click(screen.getByTestId("refresh-button"))
187+
dispatchMessage({
188+
type: "singleRouterModelFetchResponse",
189+
success: false,
190+
values: { provider: "openrouter" },
191+
error: "should not show",
192+
})
193+
194+
expect(screen.queryByText("should not show")).not.toBeInTheDocument()
195+
expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument()
196+
})
197+
198+
it("does not override an error with success when routerModels arrives after a failure", () => {
199+
renderComponent({ opencodeGoApiKey: "key" })
200+
201+
fireEvent.click(screen.getByTestId("refresh-button"))
202+
203+
// Dispatch both within the same act batch so the handler still sees
204+
// refreshStatus === "loading" and the errorJustReceived guard is exercised.
205+
act(() => {
206+
window.dispatchEvent(
207+
new MessageEvent("message", {
208+
data: {
209+
type: "singleRouterModelFetchResponse",
210+
success: false,
211+
values: { provider: "opencode-go" },
212+
error: "boom",
213+
},
214+
}),
215+
)
216+
window.dispatchEvent(new MessageEvent("message", { data: { type: "routerModels" } }))
217+
})
218+
219+
expect(screen.getByText("boom")).toBeInTheDocument()
220+
expect(screen.queryByText("settings:providers.refreshModels.success")).not.toBeInTheDocument()
221+
})
222+
223+
it("ignores routerModels messages when not in loading state", () => {
224+
renderComponent({ opencodeGoApiKey: "key" })
225+
226+
// No refresh initiated; an unsolicited routerModels message should be a no-op.
227+
dispatchMessage({ type: "routerModels" })
228+
229+
expect(screen.queryByText("settings:providers.refreshModels.success")).not.toBeInTheDocument()
230+
expect(screen.queryByText("settings:providers.refreshModels.loading")).not.toBeInTheDocument()
231+
})
232+
})
90233
})

0 commit comments

Comments
 (0)