From 6ed536e3a379296c78a2065169f91fe08304a7a9 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 14 May 2026 17:33:21 +0000 Subject: [PATCH 1/5] fix: restore settings access after Roo Router import downgrade --- webview-ui/src/App.tsx | 11 ++++- webview-ui/src/__tests__/App.spec.tsx | 63 +++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index f02da9a34a..01dff74130 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -54,6 +54,7 @@ const App = () => { const { didHydrateState, showWelcome, + settingsImportedAt, shouldShowAnnouncement, telemetrySetting, telemetryKey, @@ -169,6 +170,14 @@ const App = () => { } }, [shouldShowAnnouncement, tab]) + useEffect(() => { + if (showWelcome && settingsImportedAt && tab === "chat") { + setCurrentSection("providers") + setCurrentMarketplaceTab(undefined) + setTab("settings") + } + }, [showWelcome, settingsImportedAt, tab]) + useEffect(() => { if (didHydrateState) { telemetryClient.updateTelemetryState(telemetrySetting, telemetryKey, machineId) @@ -214,7 +223,7 @@ const App = () => { // 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 ? ( + return showWelcome && tab === "chat" ? ( ) : ( <> diff --git a/webview-ui/src/__tests__/App.spec.tsx b/webview-ui/src/__tests__/App.spec.tsx index 29d2c41977..1156f88759 100644 --- a/webview-ui/src/__tests__/App.spec.tsx +++ b/webview-ui/src/__tests__/App.spec.tsx @@ -47,6 +47,13 @@ vi.mock("@src/components/settings/SettingsView", () => ({ }, })) +vi.mock("@src/components/welcome/WelcomeViewProvider", () => ({ + __esModule: true, + default: function WelcomeView() { + return
Welcome View
+ }, +})) + vi.mock("@src/components/history/HistoryView", () => ({ __esModule: true, default: function HistoryView({ onDone }: { onDone: () => void }) { @@ -189,6 +196,22 @@ describe("App", () => { expect(chatView.getAttribute("data-hidden")).toBe("false") }, 10000) + it("shows welcome view when setup is incomplete", () => { + mockUseExtensionState.mockReturnValue({ + didHydrateState: true, + showWelcome: true, + shouldShowAnnouncement: false, + experiments: {}, + language: "en", + telemetrySetting: "enabled", + }) + + render() + + expect(screen.getByTestId("welcome-view")).toBeInTheDocument() + expect(screen.queryByTestId("settings-view")).not.toBeInTheDocument() + }) + it("switches to settings view when receiving settingsButtonClicked action", async () => { render() @@ -203,6 +226,46 @@ describe("App", () => { expect(chatView.getAttribute("data-hidden")).toBe("true") }) + it.each([ + ["settings", "settings-view"], + ["marketplace", "marketplace-view"], + ])("still switches to %s while welcome gating is active", async (action, testId) => { + mockUseExtensionState.mockReturnValue({ + didHydrateState: true, + showWelcome: true, + shouldShowAnnouncement: false, + experiments: {}, + language: "en", + telemetrySetting: "enabled", + }) + + render() + + act(() => { + triggerMessage(`${action}ButtonClicked`) + }) + + expect(await screen.findByTestId(testId)).toBeInTheDocument() + expect(screen.queryByTestId("welcome-view")).not.toBeInTheDocument() + }) + + it("opens providers settings automatically after an import leaves setup incomplete", async () => { + mockUseExtensionState.mockReturnValue({ + didHydrateState: true, + showWelcome: true, + settingsImportedAt: Date.now(), + shouldShowAnnouncement: false, + experiments: {}, + language: "en", + telemetrySetting: "enabled", + }) + + render() + + expect(await screen.findByTestId("settings-view")).toBeInTheDocument() + expect(screen.queryByTestId("welcome-view")).not.toBeInTheDocument() + }) + it("switches to history view when receiving historyButtonClicked action", async () => { render() From 3c1bedb49fbf8e65b83f14cebc77afab83697960 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 14 May 2026 18:00:46 +0000 Subject: [PATCH 2/5] Address PR feedback on Roo import welcome gating --- webview-ui/src/App.tsx | 8 +++-- webview-ui/src/__tests__/App.spec.tsx | 50 ++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 01dff74130..6dad68c581 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -68,6 +68,7 @@ const App = () => { const [showAnnouncement, setShowAnnouncement] = useState(false) const [tab, setTab] = useState("chat") + const handledImportRef = useRef(undefined) const [deleteMessageDialogState, setDeleteMessageDialogState] = useState({ isOpen: false, @@ -171,7 +172,8 @@ const App = () => { }, [shouldShowAnnouncement, tab]) useEffect(() => { - if (showWelcome && settingsImportedAt && tab === "chat") { + if (showWelcome && settingsImportedAt && tab === "chat" && settingsImportedAt !== handledImportRef.current) { + handledImportRef.current = settingsImportedAt setCurrentSection("providers") setCurrentMarketplaceTab(undefined) setTab("settings") @@ -223,7 +225,9 @@ const App = () => { // 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 && tab === "chat" ? ( + const isSetupGatedTab = showWelcome && tab !== "settings" && tab !== "marketplace" + + return isSetupGatedTab ? ( ) : ( <> diff --git a/webview-ui/src/__tests__/App.spec.tsx b/webview-ui/src/__tests__/App.spec.tsx index 1156f88759..35c0809a66 100644 --- a/webview-ui/src/__tests__/App.spec.tsx +++ b/webview-ui/src/__tests__/App.spec.tsx @@ -249,11 +249,33 @@ describe("App", () => { expect(screen.queryByTestId("welcome-view")).not.toBeInTheDocument() }) + it("keeps history behind the welcome gate while setup is incomplete", () => { + mockUseExtensionState.mockReturnValue({ + didHydrateState: true, + showWelcome: true, + shouldShowAnnouncement: false, + experiments: {}, + language: "en", + telemetrySetting: "enabled", + }) + + render() + + act(() => { + triggerMessage("historyButtonClicked") + }) + + expect(screen.getByTestId("welcome-view")).toBeInTheDocument() + expect(screen.queryByTestId("history-view")).not.toBeInTheDocument() + }) + it("opens providers settings automatically after an import leaves setup incomplete", async () => { + const importedAt = Date.now() + mockUseExtensionState.mockReturnValue({ didHydrateState: true, showWelcome: true, - settingsImportedAt: Date.now(), + settingsImportedAt: importedAt, shouldShowAnnouncement: false, experiments: {}, language: "en", @@ -266,6 +288,32 @@ describe("App", () => { expect(screen.queryByTestId("welcome-view")).not.toBeInTheDocument() }) + it("does not bounce back to settings after the import redirect has already fired", async () => { + const importedAt = Date.now() + + mockUseExtensionState.mockReturnValue({ + didHydrateState: true, + showWelcome: true, + settingsImportedAt: importedAt, + shouldShowAnnouncement: false, + experiments: {}, + language: "en", + telemetrySetting: "enabled", + }) + + render() + + const settingsView = await screen.findByTestId("settings-view") + expect(settingsView).toBeInTheDocument() + + act(() => { + settingsView.click() + }) + + expect(screen.getByTestId("welcome-view")).toBeInTheDocument() + expect(screen.queryByTestId("settings-view")).not.toBeInTheDocument() + }) + it("switches to history view when receiving historyButtonClicked action", async () => { render() From 1db33af48b63675dd4e1cb9ce43ac9956f5f7a61 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 14 May 2026 18:11:46 +0000 Subject: [PATCH 3/5] Handle Roo import redirect from gated tabs --- webview-ui/src/App.tsx | 4 +++- webview-ui/src/__tests__/App.spec.tsx | 28 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 6dad68c581..15cbbe6f5b 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -172,7 +172,9 @@ const App = () => { }, [shouldShowAnnouncement, tab]) useEffect(() => { - if (showWelcome && settingsImportedAt && tab === "chat" && settingsImportedAt !== handledImportRef.current) { + const isRecoverableTab = tab === "settings" || tab === "marketplace" + + if (showWelcome && settingsImportedAt && !isRecoverableTab && settingsImportedAt !== handledImportRef.current) { handledImportRef.current = settingsImportedAt setCurrentSection("providers") setCurrentMarketplaceTab(undefined) diff --git a/webview-ui/src/__tests__/App.spec.tsx b/webview-ui/src/__tests__/App.spec.tsx index 35c0809a66..4c9413c0fa 100644 --- a/webview-ui/src/__tests__/App.spec.tsx +++ b/webview-ui/src/__tests__/App.spec.tsx @@ -288,6 +288,34 @@ describe("App", () => { expect(screen.queryByTestId("welcome-view")).not.toBeInTheDocument() }) + it("redirects to providers settings when an import fires from another gated tab", async () => { + const state = { + didHydrateState: true, + showWelcome: true, + shouldShowAnnouncement: false, + experiments: {}, + language: "en", + telemetrySetting: "enabled", + settingsImportedAt: undefined as number | undefined, + } + + mockUseExtensionState.mockImplementation(() => state) + + const { rerender } = render() + + act(() => { + triggerMessage("historyButtonClicked") + }) + + expect(screen.getByTestId("welcome-view")).toBeInTheDocument() + + state.settingsImportedAt = Date.now() + rerender() + + expect(await screen.findByTestId("settings-view")).toBeInTheDocument() + expect(screen.queryByTestId("welcome-view")).not.toBeInTheDocument() + }) + it("does not bounce back to settings after the import redirect has already fired", async () => { const importedAt = Date.now() From af1f22af7c7293640601f3e2b93b6bdff78d1b44 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 14 May 2026 18:34:11 +0000 Subject: [PATCH 4/5] Add tab-state matrix for Roo import redirect --- webview-ui/src/App.tsx | 10 ++- webview-ui/src/__tests__/App.spec.tsx | 125 ++++++++++++++++++-------- 2 files changed, 95 insertions(+), 40 deletions(-) diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 15cbbe6f5b..6f39e806d0 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -174,11 +174,13 @@ const App = () => { useEffect(() => { const isRecoverableTab = tab === "settings" || tab === "marketplace" - if (showWelcome && settingsImportedAt && !isRecoverableTab && settingsImportedAt !== handledImportRef.current) { + if (showWelcome && settingsImportedAt && settingsImportedAt !== handledImportRef.current) { handledImportRef.current = settingsImportedAt - setCurrentSection("providers") - setCurrentMarketplaceTab(undefined) - setTab("settings") + if (!isRecoverableTab) { + setCurrentSection("providers") + setCurrentMarketplaceTab(undefined) + setTab("settings") + } } }, [showWelcome, settingsImportedAt, tab]) diff --git a/webview-ui/src/__tests__/App.spec.tsx b/webview-ui/src/__tests__/App.spec.tsx index 4c9413c0fa..137bed5d70 100644 --- a/webview-ui/src/__tests__/App.spec.tsx +++ b/webview-ui/src/__tests__/App.spec.tsx @@ -188,6 +188,15 @@ describe("App", () => { window.dispatchEvent(messageEvent) } + const createSetupIncompleteState = () => ({ + didHydrateState: true, + showWelcome: true, + shouldShowAnnouncement: false, + experiments: {}, + language: "en", + telemetrySetting: "enabled", + }) + it("shows chat view by default", () => { render() @@ -269,33 +278,12 @@ describe("App", () => { expect(screen.queryByTestId("history-view")).not.toBeInTheDocument() }) - it("opens providers settings automatically after an import leaves setup incomplete", async () => { - const importedAt = Date.now() - - mockUseExtensionState.mockReturnValue({ - didHydrateState: true, - showWelcome: true, - settingsImportedAt: importedAt, - shouldShowAnnouncement: false, - experiments: {}, - language: "en", - telemetrySetting: "enabled", - }) - - render() - - expect(await screen.findByTestId("settings-view")).toBeInTheDocument() - expect(screen.queryByTestId("welcome-view")).not.toBeInTheDocument() - }) - - it("redirects to providers settings when an import fires from another gated tab", async () => { + it.each([ + { label: "chat", action: undefined }, + { label: "history", action: "historyButtonClicked" }, + ])("redirects to providers settings when an import fires from the $label tab", async ({ action }) => { const state = { - didHydrateState: true, - showWelcome: true, - shouldShowAnnouncement: false, - experiments: {}, - language: "en", - telemetrySetting: "enabled", + ...createSetupIncompleteState(), settingsImportedAt: undefined as number | undefined, } @@ -303,11 +291,15 @@ describe("App", () => { const { rerender } = render() - act(() => { - triggerMessage("historyButtonClicked") - }) + if (action) { + act(() => { + triggerMessage(action) + }) + } - expect(screen.getByTestId("welcome-view")).toBeInTheDocument() + if (action === "historyButtonClicked") { + expect(screen.getByTestId("welcome-view")).toBeInTheDocument() + } state.settingsImportedAt = Date.now() rerender() @@ -316,17 +308,78 @@ describe("App", () => { expect(screen.queryByTestId("welcome-view")).not.toBeInTheDocument() }) + it.each([ + { + label: "settings before returning to chat", + action: "settingsButtonClicked", + viewId: "settings-view", + nextAction: undefined, + }, + { + label: "settings before switching to history", + action: "settingsButtonClicked", + viewId: "settings-view", + nextAction: "historyButtonClicked", + }, + { + label: "marketplace before returning to chat", + action: "marketplaceButtonClicked", + viewId: "marketplace-view", + nextAction: undefined, + }, + { + label: "marketplace before switching to history", + action: "marketplaceButtonClicked", + viewId: "marketplace-view", + nextAction: "historyButtonClicked", + }, + ])( + "consumes imported settings without a later redirect when already on $label", + async ({ action, viewId, nextAction }) => { + const state = { + ...createSetupIncompleteState(), + settingsImportedAt: undefined as number | undefined, + } + + mockUseExtensionState.mockImplementation(() => state) + + const { rerender } = render() + + act(() => { + triggerMessage(action) + }) + + expect(await screen.findByTestId(viewId)).toBeInTheDocument() + expect(screen.queryByTestId("welcome-view")).not.toBeInTheDocument() + + state.settingsImportedAt = Date.now() + rerender() + + const currentView = await screen.findByTestId(viewId) + expect(currentView).toBeInTheDocument() + + if (nextAction) { + act(() => { + triggerMessage(nextAction) + }) + } else { + act(() => { + currentView.click() + }) + } + + expect(screen.getByTestId("welcome-view")).toBeInTheDocument() + expect(screen.queryByTestId("settings-view")).not.toBeInTheDocument() + expect(screen.queryByTestId("marketplace-view")).not.toBeInTheDocument() + }, + ) + it("does not bounce back to settings after the import redirect has already fired", async () => { const importedAt = Date.now() mockUseExtensionState.mockReturnValue({ - didHydrateState: true, - showWelcome: true, + ...createSetupIncompleteState(), settingsImportedAt: importedAt, - shouldShowAnnouncement: false, - experiments: {}, - language: "en", - telemetrySetting: "enabled", }) render() From 82d35cf0f52c68d8e85b5f8c5019c3a1469a6a64 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 14 May 2026 19:03:11 +0000 Subject: [PATCH 5/5] Consume imported settings timestamp after sync --- .../config/__tests__/importExport.spec.ts | 63 +++++++++++++++++-- src/core/config/importExport.ts | 1 + 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index dcd7f0089c..74bb44aff4 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -731,9 +731,12 @@ describe("importExport", () => { { name: "valid-profile", id: "valid-id", apiProvider: "openai" as ProviderName }, ]) + const seenImportedAt: Array = [] const mockProvider = { - settingsImportedAt: 0, - postStateToWebview: vi.fn().mockResolvedValue(undefined), + settingsImportedAt: undefined as number | undefined, + postStateToWebview: vi.fn().mockImplementation(async () => { + seenImportedAt.push(mockProvider.settingsImportedAt) + }), } const showWarningMessageSpy = vi.spyOn(vscode.window, "showWarningMessage").mockResolvedValue(undefined) @@ -766,8 +769,10 @@ describe("importExport", () => { ) expect(showInfoMessageSpy).not.toHaveBeenCalled() - // Provider state should still be updated - expect(mockProvider.settingsImportedAt).toBeGreaterThan(0) + // Provider state should be delivered once, then cleared. + expect(seenImportedAt).toHaveLength(1) + expect(seenImportedAt[0]).toBeGreaterThan(0) + expect(mockProvider.settingsImportedAt).toBeUndefined() expect(mockProvider.postStateToWebview).toHaveBeenCalled() showWarningMessageSpy.mockRestore() @@ -775,6 +780,56 @@ describe("importExport", () => { consoleWarnSpy.mockRestore() }) + it("clears settingsImportedAt after posting the imported state so later launches do not replay it", async () => { + const filePath = "/mock/path/settings.json" + const mockFileContent = JSON.stringify({ + providerProfiles: { + currentApiConfigName: "valid-profile", + apiConfigs: { + "valid-profile": { + apiProvider: "openai" as ProviderName, + apiKey: "test-key", + id: "valid-id", + }, + }, + }, + globalSettings: { mode: "code" }, + }) + + ;(fs.readFile as Mock).mockResolvedValue(mockFileContent) + ;(fs.access as Mock).mockResolvedValue(undefined) + + mockProviderSettingsManager.export.mockResolvedValue({ + currentApiConfigName: "default", + apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + }) + mockProviderSettingsManager.listConfig.mockResolvedValue([ + { name: "valid-profile", id: "valid-id", apiProvider: "openai" as ProviderName }, + ]) + + const seenImportedAt: Array = [] + const mockProvider = { + settingsImportedAt: undefined as number | undefined, + postStateToWebview: vi.fn().mockImplementation(async () => { + seenImportedAt.push(mockProvider.settingsImportedAt) + }), + } + + await importSettingsWithFeedback( + { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + provider: mockProvider, + }, + filePath, + ) + + expect(seenImportedAt).toHaveLength(1) + expect(seenImportedAt[0]).toBeGreaterThan(0) + expect(mockProvider.settingsImportedAt).toBeUndefined() + }) + it("should handle multiple profiles with mixed valid and invalid providers", async () => { ;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }]) diff --git a/src/core/config/importExport.ts b/src/core/config/importExport.ts index 050a43191e..c2adb982c6 100644 --- a/src/core/config/importExport.ts +++ b/src/core/config/importExport.ts @@ -327,6 +327,7 @@ export const importSettingsWithFeedback = async ( if (result.success) { provider.settingsImportedAt = Date.now() await provider.postStateToWebview() + provider.settingsImportedAt = undefined const warnings = "warnings" in result ? result.warnings : undefined // Show warnings if any profiles had issues but were still imported (with modifications)