Skip to content

Commit 9a246c8

Browse files
committed
fix(webview): add loading skeleton for view state initialization
1 parent 02fce95 commit 9a246c8

4 files changed

Lines changed: 95 additions & 2 deletions

File tree

webview-ui/src/components/modes/ModesView.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ const ModesView = () => {
7777
setCustomInstructions,
7878
customModes,
7979
mcpServers,
80+
viewStateLoaded,
8081
} = useExtensionState()
8182

8283
// Use a local state to track the visually active mode
@@ -613,6 +614,17 @@ const ModesView = () => {
613614
})
614615
}
615616

617+
if (viewStateLoaded === false) {
618+
return (
619+
<div data-testid="modes-view-loading-skeleton" className="p-4" aria-busy="true">
620+
<div className="mb-4 h-7 w-24 animate-pulse rounded bg-vscode-input-background" />
621+
<div className="mb-3 h-8 w-full animate-pulse rounded bg-vscode-input-background" />
622+
<div className="mb-4 h-16 w-full animate-pulse rounded bg-vscode-input-background" />
623+
<div className="mb-4 h-16 w-full animate-pulse rounded bg-vscode-input-background" />
624+
</div>
625+
)
626+
}
627+
616628
return (
617629
<div>
618630
<Section>

webview-ui/src/components/modes/__tests__/ModesView.spec.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ const mockExtensionState = {
2626
currentApiConfigName: "",
2727
customInstructions: "Initial instructions",
2828
setCustomInstructions: vitest.fn(),
29+
viewStateLoaded: true,
30+
setViewStateLoaded: vitest.fn(),
2931
}
3032

3133
const renderPromptsView = (props = {}) => {
@@ -43,6 +45,19 @@ describe("PromptsView", () => {
4345
vitest.clearAllMocks()
4446
})
4547

48+
it("shows a loading skeleton while view-local state is initializing", () => {
49+
renderPromptsView({
50+
viewStateLoaded: false,
51+
mode: "debug",
52+
currentApiConfigName: "stale-global-profile",
53+
})
54+
55+
expect(screen.getByTestId("modes-view-loading-skeleton")).toBeInTheDocument()
56+
expect(screen.queryByTestId("mode-select-trigger")).not.toBeInTheDocument()
57+
expect(screen.queryByText("Debug")).not.toBeInTheDocument()
58+
expect(screen.queryByText("stale-global-profile")).not.toBeInTheDocument()
59+
})
60+
4661
it("displays the current mode name in the select trigger", () => {
4762
renderPromptsView({ mode: "code" })
4863
const selectTrigger = screen.getByTestId("mode-select-trigger")

webview-ui/src/context/ExtensionStateContext.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ import { convertTextMateToHljs } from "@src/utils/textMateToHljs"
3636
export interface ExtensionStateContextType extends ExtensionState {
3737
historyPreviewCollapsed?: boolean // Add the new state property
3838
didHydrateState: boolean
39+
viewStateLoaded: boolean
40+
setViewStateLoaded: (value: boolean) => void
3941
showWelcome: boolean
4042
theme: any
4143
mcpServers: McpServer[]
@@ -274,6 +276,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
274276
})
275277

276278
const [didHydrateState, setDidHydrateState] = useState(false)
279+
const [viewStateLoaded, setViewStateLoaded] = useState(false)
277280
const [showWelcome, setShowWelcome] = useState(false)
278281
const [theme, setTheme] = useState<any>(undefined)
279282
const [filePaths, setFilePaths] = useState<string[]>([])
@@ -319,6 +322,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
319322
setState((prevState) => mergeExtensionState(prevState, newState))
320323
setShowWelcome(!checkExistKey(newState.apiConfiguration))
321324
setDidHydrateState(true)
325+
setViewStateLoaded(true)
322326
// Update alwaysAllowFollowupQuestions if present in state message
323327
if ((newState as any).alwaysAllowFollowupQuestions !== undefined) {
324328
setAlwaysAllowFollowupQuestions((newState as any).alwaysAllowFollowupQuestions)
@@ -513,6 +517,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
513517
chatFontSize: state.chatFontSize ?? undefined,
514518
reasoningBlockCollapsed: state.reasoningBlockCollapsed ?? true,
515519
didHydrateState,
520+
viewStateLoaded,
521+
setViewStateLoaded,
516522
showWelcome,
517523
theme,
518524
mcpServers,

webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,31 @@ import {
1313
import { ExtensionStateContextProvider, useExtensionState, mergeExtensionState } from "../ExtensionStateContext"
1414

1515
const TestComponent = () => {
16-
const { allowedCommands, setAllowedCommands, soundEnabled, showRooIgnoredFiles, setShowRooIgnoredFiles } =
17-
useExtensionState()
16+
const {
17+
allowedCommands,
18+
setAllowedCommands,
19+
soundEnabled,
20+
showRooIgnoredFiles,
21+
setShowRooIgnoredFiles,
22+
viewStateLoaded,
23+
setViewStateLoaded,
24+
} = useExtensionState()
1825

1926
return (
2027
<div>
2128
<div data-testid="allowed-commands">{JSON.stringify(allowedCommands)}</div>
2229
<div data-testid="sound-enabled">{JSON.stringify(soundEnabled)}</div>
2330
<div data-testid="show-rooignored-files">{JSON.stringify(showRooIgnoredFiles)}</div>
31+
<div data-testid="view-state-loaded">{JSON.stringify(viewStateLoaded)}</div>
2432
<button data-testid="update-button" onClick={() => setAllowedCommands(["npm install", "git status"])}>
2533
Update Commands
2634
</button>
2735
<button data-testid="toggle-rooignore-button" onClick={() => setShowRooIgnoredFiles(!showRooIgnoredFiles)}>
2836
Update Commands
2937
</button>
38+
<button data-testid="set-view-state-loaded-button" onClick={() => setViewStateLoaded(true)}>
39+
Set View State Loaded
40+
</button>
3041
</div>
3142
)
3243
}
@@ -92,6 +103,55 @@ describe("ExtensionStateContext", () => {
92103
expect(JSON.parse(screen.getByTestId("rules").textContent!)).toEqual([])
93104
})
94105

106+
it("initializes with viewStateLoaded set to false", () => {
107+
render(
108+
<ExtensionStateContextProvider>
109+
<TestComponent />
110+
</ExtensionStateContextProvider>,
111+
)
112+
113+
expect(JSON.parse(screen.getByTestId("view-state-loaded").textContent!)).toBe(false)
114+
})
115+
116+
it("marks viewStateLoaded true after receiving initial state", () => {
117+
render(
118+
<ExtensionStateContextProvider>
119+
<TestComponent />
120+
</ExtensionStateContextProvider>,
121+
)
122+
123+
act(() => {
124+
window.dispatchEvent(
125+
new MessageEvent("message", {
126+
data: {
127+
type: "state",
128+
state: {
129+
apiConfiguration: { apiProvider: "anthropic" },
130+
mode: "ask",
131+
currentApiConfigName: "view-local-profile",
132+
},
133+
},
134+
}),
135+
)
136+
})
137+
138+
expect(JSON.parse(screen.getByTestId("view-state-loaded").textContent!)).toBe(true)
139+
})
140+
141+
it("updates viewStateLoaded through setViewStateLoaded", () => {
142+
render(
143+
<ExtensionStateContextProvider>
144+
<TestComponent />
145+
</ExtensionStateContextProvider>,
146+
)
147+
148+
act(() => {
149+
screen.getByTestId("set-view-state-loaded-button").click()
150+
})
151+
152+
expect(JSON.parse(screen.getByTestId("view-state-loaded").textContent!)).toBe(true)
153+
})
154+
95155
it("updates rules from incoming rules message", () => {
96156
render(
97157
<ExtensionStateContextProvider>

0 commit comments

Comments
 (0)