Skip to content

Commit b45cccd

Browse files
committed
fix(desktop): hide models and controls while an engine is stopped
Signed-off-by: nvrithik <rithikv@nvidia.com>
1 parent 2e300f2 commit b45cccd

3 files changed

Lines changed: 238 additions & 20 deletions

File tree

‎desktop/src/ui/components/ModelManager/ModelManager.tsx‎

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,6 @@ export function ModelManager({ backend, nodeId }: { backend: BackendInfo; nodeId
159159
() => caps?.hasModelSearchOnlyWhenRunning ?? false,
160160
[caps?.hasModelSearchOnlyWhenRunning]
161161
)
162-
const modelOpsWhenStopped = useMemo(
163-
() => caps?.modelOpsWhenStopped ?? false,
164-
[caps?.modelOpsWhenStopped]
165-
)
166162
const isRunning = useMemo(() => backend.processStatus === 'running', [backend.processStatus])
167163
const supportsSearch = useMemo(
168164
() => (caps?.hasPullModel ?? false) && (!hasModelSearchOnlyWhenRunning || isRunning),
@@ -184,6 +180,14 @@ export function ModelManager({ backend, nodeId }: { backend: BackendInfo; nodeId
184180
[backendType, nodeId]
185181
)
186182

183+
if (backend.processStatus === 'stopped') {
184+
return (
185+
<Text kind="body/regular/sm" className="text-subtle-color pl-2">
186+
Start engine to see models
187+
</Text>
188+
)
189+
}
190+
187191
return (
188192
<Stack gap="4">
189193
{transientModel && (
@@ -200,22 +204,11 @@ export function ModelManager({ backend, nodeId }: { backend: BackendInfo; nodeId
200204

201205
{!isBusy && (
202206
<>
203-
{(isRunning || modelOpsWhenStopped) &&
204-
models.length === 0 &&
205-
incomingSyncs.length === 0 && (
206-
<Text kind="body/regular/sm" className="text-subtle-color pl-2">
207-
No model/weight files downloaded
208-
</Text>
209-
)}
210-
{!modelOpsWhenStopped &&
211-
models.length === 0 &&
212-
incomingSyncs.length === 0 &&
213-
backend.processStatus === 'stopped' && (
214-
<Text kind="body/regular/sm" className="text-subtle-color pl-2">
215-
Start engine to see models
216-
</Text>
217-
)}
218-
207+
{isRunning && models.length === 0 && incomingSyncs.length === 0 && (
208+
<Text kind="body/regular/sm" className="text-subtle-color pl-2">
209+
No model/weight files downloaded
210+
</Text>
211+
)}
219212
{models.length > 0 && (
220213
<Stack gap="1">
221214
{models.map(model => (
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { createElement } from 'react'
5+
import { renderToStaticMarkup } from 'react-dom/server'
6+
import { afterEach, describe, expect, it } from 'vitest'
7+
import { EngineDisplayNames, EngineTypes } from '@/shared/constants/engines'
8+
import type { EngineType } from '@/shared/types/engines'
9+
import { ModelManager } from '@/ui/components/ModelManager/ModelManager'
10+
import { ModelSection } from '@/ui/components/ModelManager/ModelSection'
11+
import { engineProgressKey } from '@/shared/utils/engine-progress'
12+
import type { EngineProgress, ModelItemStatus } from '@/shared/types/engines'
13+
import type { BackendInfo, ModelItem } from '@/ui/types/engine-info'
14+
import { useEngineProgressStore } from '@/ui/stores/engine-progress.store'
15+
import { usePendingActionsStore } from '@/ui/stores/pending-actions.store'
16+
17+
function cachedModel(): ModelItem {
18+
return {
19+
name: 'Qwen/Qwen2.5-1.5B-Instruct',
20+
size: 1024,
21+
downloaded: true,
22+
status: 'idle',
23+
parameterSize: '1.5B',
24+
quantization: '',
25+
family: 'qwen2',
26+
digest: '',
27+
sizeVram: null,
28+
expiresAt: null,
29+
expiry: '5m',
30+
capabilities: []
31+
}
32+
}
33+
34+
function engineInfo(type: EngineType, overrides: Partial<BackendInfo> = {}): BackendInfo {
35+
return {
36+
type,
37+
displayName: EngineDisplayNames[type],
38+
source: 'installed',
39+
processStatus: 'stopped',
40+
port: 8001,
41+
proxyPort: 8000,
42+
models: [],
43+
...overrides
44+
}
45+
}
46+
47+
afterEach(() => {
48+
useEngineProgressStore.getState().cleanup()
49+
usePendingActionsStore.getState().cleanup()
50+
})
51+
52+
describe.each(EngineTypes)('%s model visibility', engineType => {
53+
it.each(['local-node', 'peer-node'])('shows the start message when stopped on %s', nodeId => {
54+
const html = renderToStaticMarkup(
55+
createElement(ModelManager, { backend: engineInfo(engineType), nodeId })
56+
)
57+
58+
expect(html).toContain('Start engine to see models')
59+
expect(html).not.toContain('No model/weight files downloaded')
60+
expect(html).not.toContain('Add model')
61+
})
62+
63+
it.each(['local-node', 'peer-node'])(
64+
'hides cached models and controls when stopped on %s',
65+
nodeId => {
66+
const html = renderToStaticMarkup(
67+
createElement(ModelSection, {
68+
backend: engineInfo(engineType, { models: [cachedModel()] }),
69+
nodeId
70+
})
71+
)
72+
73+
expect(html).toContain('Start engine to see models')
74+
expect(html).not.toContain(cachedModel().name)
75+
expect(html).not.toContain('Open ')
76+
expect(html).not.toContain('Add model')
77+
}
78+
)
79+
80+
it.each(['local-node', 'peer-node'])(
81+
'shows the empty inventory and Add model when running on %s',
82+
nodeId => {
83+
const html = renderToStaticMarkup(
84+
createElement(ModelManager, {
85+
backend: engineInfo(engineType, { processStatus: 'running' }),
86+
nodeId
87+
})
88+
)
89+
90+
expect(html).toContain('No model/weight files downloaded')
91+
expect(html).toContain('Add model')
92+
expect(html).not.toContain('Start engine to see models')
93+
}
94+
)
95+
96+
it.each(['local-node', 'peer-node'])(
97+
'shows downloaded models and controls when running on %s',
98+
nodeId => {
99+
const html = renderToStaticMarkup(
100+
createElement(ModelManager, {
101+
backend: engineInfo(engineType, {
102+
processStatus: 'running',
103+
models: [cachedModel()]
104+
}),
105+
nodeId
106+
})
107+
)
108+
109+
expect(html).toContain(cachedModel().name)
110+
expect(html).toContain('actions')
111+
expect(html).toContain('Add model')
112+
expect(html).not.toContain('Start engine to see models')
113+
expect(html).not.toContain('No model/weight files downloaded')
114+
}
115+
)
116+
})
117+
118+
describe.each(['local-node', 'peer-node'])('vLLM state details on %s', nodeId => {
119+
it('keeps models and controls visible when the engine is on but idle', () => {
120+
const html = renderToStaticMarkup(
121+
createElement(ModelManager, {
122+
backend: engineInfo('vllm', {
123+
processStatus: 'running',
124+
idle: true,
125+
models: [cachedModel()]
126+
}),
127+
nodeId
128+
})
129+
)
130+
131+
expect(html).toContain(cachedModel().name)
132+
expect(html).toContain('Unloads after 5m idle')
133+
expect(html).toContain('Add model')
134+
expect(html).not.toContain('Start engine to see models')
135+
})
136+
137+
it('shows an empty inventory when on but idle with no cached models', () => {
138+
const html = renderToStaticMarkup(
139+
createElement(ModelManager, {
140+
backend: engineInfo('vllm', { processStatus: 'running', idle: true }),
141+
nodeId
142+
})
143+
)
144+
145+
expect(html).toContain('No model/weight files downloaded')
146+
expect(html).toContain('Add model')
147+
expect(html).not.toContain('Start engine to see models')
148+
})
149+
150+
it.each<ModelItemStatus>(['loaded', 'loading', 'ejecting', 'pulling'])(
151+
'hides a stale %s model when stopped',
152+
status => {
153+
const html = renderToStaticMarkup(
154+
createElement(ModelManager, {
155+
backend: engineInfo('vllm', { models: [{ ...cachedModel(), status }] }),
156+
nodeId
157+
})
158+
)
159+
160+
expect(html).toContain('Start engine to see models')
161+
expect(html).not.toContain('role="status"')
162+
expect(html).not.toContain(cachedModel().name)
163+
expect(html).not.toContain('Add model')
164+
}
165+
)
166+
167+
it('hides stale download progress while stopped and restores it when running', () => {
168+
const progress: EngineProgress = {
169+
nodeId,
170+
nodeName: 'Test node',
171+
engineType: 'vllm',
172+
operation: 'pull',
173+
model: 'org/incoming-model',
174+
status: 'downloading',
175+
percent: 25
176+
}
177+
useEngineProgressStore.setState({
178+
progress: new Map([[engineProgressKey(progress), progress]])
179+
})
180+
181+
const stopped = renderToStaticMarkup(
182+
createElement(ModelManager, { backend: engineInfo('vllm'), nodeId })
183+
)
184+
expect(stopped).toContain('Start engine to see models')
185+
expect(stopped).not.toContain('org/incoming-model')
186+
expect(stopped).not.toContain('role="status"')
187+
188+
const running = renderToStaticMarkup(
189+
createElement(ModelManager, {
190+
backend: engineInfo('vllm', { processStatus: 'running' }),
191+
nodeId
192+
})
193+
)
194+
expect(running).toContain('org/incoming-model')
195+
expect(running).toContain('role="status"')
196+
expect(running).not.toContain('No model/weight files downloaded')
197+
})
198+
199+
it('does not use another node’s progress to hide the running empty-state message', () => {
200+
const progress: EngineProgress = {
201+
nodeId: 'unrelated-node',
202+
nodeName: 'Other node',
203+
engineType: 'vllm',
204+
operation: 'pull',
205+
model: 'org/other-node-model',
206+
status: 'downloading'
207+
}
208+
useEngineProgressStore.setState({
209+
progress: new Map([[engineProgressKey(progress), progress]])
210+
})
211+
212+
const html = renderToStaticMarkup(
213+
createElement(ModelManager, {
214+
backend: engineInfo('vllm', { processStatus: 'running' }),
215+
nodeId
216+
})
217+
)
218+
219+
expect(html).toContain('No model/weight files downloaded')
220+
expect(html).not.toContain('org/other-node-model')
221+
})
222+
})

‎desktop/vitest.config.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ import * as path from 'path'
2121
* to the public internet) trips a hard fail before a test body runs.
2222
*/
2323
export default defineConfig({
24+
esbuild: {
25+
jsx: 'automatic'
26+
},
2427
resolve: {
2528
alias: {
2629
'@': path.resolve(__dirname, 'src')

0 commit comments

Comments
 (0)