From 414aa5d1506fecb969cff6a94b8743fd13c7e124 Mon Sep 17 00:00:00 2001 From: nvrithik Date: Mon, 2 Nov 2026 18:36:05 +0530 Subject: [PATCH 1/2] fix(desktop): hide models and controls while an engine is stopped Signed-off-by: nvrithik --- .../components/ModelManager/ModelManager.tsx | 23 +- .../modular/model-manager-visibility.test.ts | 222 ++++++++++++++++++ desktop/vitest.config.ts | 3 + 3 files changed, 234 insertions(+), 14 deletions(-) create mode 100644 desktop/tests/modular/model-manager-visibility.test.ts diff --git a/desktop/src/ui/components/ModelManager/ModelManager.tsx b/desktop/src/ui/components/ModelManager/ModelManager.tsx index 648448346..180ba87d6 100644 --- a/desktop/src/ui/components/ModelManager/ModelManager.tsx +++ b/desktop/src/ui/components/ModelManager/ModelManager.tsx @@ -159,10 +159,6 @@ export function ModelManager({ backend, nodeId }: { backend: BackendInfo; nodeId () => caps?.hasModelSearchOnlyWhenRunning ?? false, [caps?.hasModelSearchOnlyWhenRunning] ) - const modelOpsWhenStopped = useMemo( - () => caps?.modelOpsWhenStopped ?? false, - [caps?.modelOpsWhenStopped] - ) const isRunning = useMemo(() => backend.processStatus === 'running', [backend.processStatus]) const supportsSearch = useMemo( () => caps !== undefined && (!hasModelSearchOnlyWhenRunning || isRunning), @@ -184,6 +180,14 @@ export function ModelManager({ backend, nodeId }: { backend: BackendInfo; nodeId [backendType, nodeId] ) + if (backend.processStatus === 'stopped') { + return ( + + Start engine to see models + + ) + } + return ( {transientModel && ( @@ -200,7 +204,7 @@ export function ModelManager({ backend, nodeId }: { backend: BackendInfo; nodeId {!isBusy && ( <> - {(isRunning || modelOpsWhenStopped) && + {isRunning && models.length === 0 && incomingSyncs.length === 0 && (backend.modelsKnown ? ( @@ -212,15 +216,6 @@ export function ModelManager({ backend, nodeId }: { backend: BackendInfo; nodeId Downloaded models unavailable ))} - {!modelOpsWhenStopped && - models.length === 0 && - incomingSyncs.length === 0 && - backend.processStatus === 'stopped' && ( - - Start engine to see models - - )} - {models.length > 0 && ( {models.map(model => ( diff --git a/desktop/tests/modular/model-manager-visibility.test.ts b/desktop/tests/modular/model-manager-visibility.test.ts new file mode 100644 index 000000000..28a3c5ac8 --- /dev/null +++ b/desktop/tests/modular/model-manager-visibility.test.ts @@ -0,0 +1,222 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, describe, expect, it } from 'vitest' +import { EngineDisplayNames, EngineTypes } from '@/shared/constants/engines' +import type { EngineType } from '@/shared/types/engines' +import { ModelManager } from '@/ui/components/ModelManager/ModelManager' +import { ModelSection } from '@/ui/components/ModelManager/ModelSection' +import { engineProgressKey } from '@/shared/utils/engine-progress' +import type { EngineProgress, ModelItemStatus } from '@/shared/types/engines' +import type { BackendInfo, ModelItem } from '@/ui/types/engine-info' +import { useEngineProgressStore } from '@/ui/stores/engine-progress.store' +import { usePendingActionsStore } from '@/ui/stores/pending-actions.store' + +function cachedModel(): ModelItem { + return { + name: 'Qwen/Qwen2.5-1.5B-Instruct', + size: 1024, + downloaded: true, + status: 'idle', + parameterSize: '1.5B', + quantization: '', + family: 'qwen2', + digest: '', + sizeVram: null, + expiresAt: null, + expiry: '5m', + capabilities: [] + } +} + +function engineInfo(type: EngineType, overrides: Partial = {}): BackendInfo { + return { + type, + displayName: EngineDisplayNames[type], + source: 'installed', + processStatus: 'stopped', + port: 8001, + proxyPort: 8000, + models: [], + ...overrides + } +} + +afterEach(() => { + useEngineProgressStore.getState().cleanup() + usePendingActionsStore.getState().cleanup() +}) + +describe.each(EngineTypes)('%s model visibility', engineType => { + it.each(['local-node', 'peer-node'])('shows the start message when stopped on %s', nodeId => { + const html = renderToStaticMarkup( + createElement(ModelManager, { backend: engineInfo(engineType), nodeId }) + ) + + expect(html).toContain('Start engine to see models') + expect(html).not.toContain('No model/weight files downloaded') + expect(html).not.toContain('Add model') + }) + + it.each(['local-node', 'peer-node'])( + 'hides cached models and controls when stopped on %s', + nodeId => { + const html = renderToStaticMarkup( + createElement(ModelSection, { + backend: engineInfo(engineType, { models: [cachedModel()] }), + nodeId + }) + ) + + expect(html).toContain('Start engine to see models') + expect(html).not.toContain(cachedModel().name) + expect(html).not.toContain('Open ') + expect(html).not.toContain('Add model') + } + ) + + it.each(['local-node', 'peer-node'])( + 'shows the empty inventory and Add model when running on %s', + nodeId => { + const html = renderToStaticMarkup( + createElement(ModelManager, { + backend: engineInfo(engineType, { processStatus: 'running' }), + nodeId + }) + ) + + expect(html).toContain('No model/weight files downloaded') + expect(html).toContain('Add model') + expect(html).not.toContain('Start engine to see models') + } + ) + + it.each(['local-node', 'peer-node'])( + 'shows downloaded models and controls when running on %s', + nodeId => { + const html = renderToStaticMarkup( + createElement(ModelManager, { + backend: engineInfo(engineType, { + processStatus: 'running', + models: [cachedModel()] + }), + nodeId + }) + ) + + expect(html).toContain(cachedModel().name) + expect(html).toContain('actions') + expect(html).toContain('Add model') + expect(html).not.toContain('Start engine to see models') + expect(html).not.toContain('No model/weight files downloaded') + } + ) +}) + +describe.each(['local-node', 'peer-node'])('vLLM state details on %s', nodeId => { + it('keeps models and controls visible when the engine is on but idle', () => { + const html = renderToStaticMarkup( + createElement(ModelManager, { + backend: engineInfo('vllm', { + processStatus: 'running', + idle: true, + models: [cachedModel()] + }), + nodeId + }) + ) + + expect(html).toContain(cachedModel().name) + expect(html).toContain('Unloads after 5m idle') + expect(html).toContain('Add model') + expect(html).not.toContain('Start engine to see models') + }) + + it('shows an empty inventory when on but idle with no cached models', () => { + const html = renderToStaticMarkup( + createElement(ModelManager, { + backend: engineInfo('vllm', { processStatus: 'running', idle: true }), + nodeId + }) + ) + + expect(html).toContain('No model/weight files downloaded') + expect(html).toContain('Add model') + expect(html).not.toContain('Start engine to see models') + }) + + it.each(['loaded', 'loading', 'ejecting', 'pulling'])( + 'hides a stale %s model when stopped', + status => { + const html = renderToStaticMarkup( + createElement(ModelManager, { + backend: engineInfo('vllm', { models: [{ ...cachedModel(), status }] }), + nodeId + }) + ) + + expect(html).toContain('Start engine to see models') + expect(html).not.toContain('role="status"') + expect(html).not.toContain(cachedModel().name) + expect(html).not.toContain('Add model') + } + ) + + it('hides stale download progress while stopped and restores it when running', () => { + const progress: EngineProgress = { + nodeId, + nodeName: 'Test node', + engineType: 'vllm', + operation: 'pull', + model: 'org/incoming-model', + status: 'downloading', + percent: 25 + } + useEngineProgressStore.setState({ + progress: new Map([[engineProgressKey(progress), progress]]) + }) + + const stopped = renderToStaticMarkup( + createElement(ModelManager, { backend: engineInfo('vllm'), nodeId }) + ) + expect(stopped).toContain('Start engine to see models') + expect(stopped).not.toContain('org/incoming-model') + expect(stopped).not.toContain('role="status"') + + const running = renderToStaticMarkup( + createElement(ModelManager, { + backend: engineInfo('vllm', { processStatus: 'running' }), + nodeId + }) + ) + expect(running).toContain('org/incoming-model') + expect(running).toContain('role="status"') + expect(running).not.toContain('No model/weight files downloaded') + }) + + it('does not use another node’s progress to hide the running empty-state message', () => { + const progress: EngineProgress = { + nodeId: 'unrelated-node', + nodeName: 'Other node', + engineType: 'vllm', + operation: 'pull', + model: 'org/other-node-model', + status: 'downloading' + } + useEngineProgressStore.setState({ + progress: new Map([[engineProgressKey(progress), progress]]) + }) + + const html = renderToStaticMarkup( + createElement(ModelManager, { + backend: engineInfo('vllm', { processStatus: 'running' }), + nodeId + }) + ) + + expect(html).toContain('No model/weight files downloaded') + expect(html).not.toContain('org/other-node-model') + }) +}) diff --git a/desktop/vitest.config.ts b/desktop/vitest.config.ts index 9a666d120..a8fd8a55b 100644 --- a/desktop/vitest.config.ts +++ b/desktop/vitest.config.ts @@ -21,6 +21,9 @@ import * as path from 'path' * to the public internet) trips a hard fail before a test body runs. */ export default defineConfig({ + esbuild: { + jsx: 'automatic' + }, resolve: { alias: { '@': path.resolve(__dirname, 'src') From dcf06e20c4846a5fdb65b92924ed08f5a25a255d Mon Sep 17 00:00:00 2001 From: nvrithik Date: Tue, 3 Nov 2026 14:06:09 +0530 Subject: [PATCH 2/2] fix(desktop): reset model dialogs and disable transitional controls Signed-off-by: nvrithik --- desktop/package-lock.json | 1 + desktop/package.json | 1 + .../components/ModelManager/ModelManager.tsx | 32 +- .../ui/components/ModelManager/ModelRow.tsx | 11 +- .../ModelManager/ModelRowHeader.tsx | 7 +- .../ModelManager/ModelRowSecondaryRow.tsx | 6 +- .../modular/model-manager-transitions.test.ts | 327 ++++++++++++++++++ .../modular/model-manager-visibility.test.ts | 24 +- 8 files changed, 383 insertions(+), 26 deletions(-) create mode 100644 desktop/tests/modular/model-manager-transitions.test.ts diff --git a/desktop/package-lock.json b/desktop/package-lock.json index cae519631..1926347df 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -42,6 +42,7 @@ "eslint-plugin-react": "^7.37.4", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", + "jsdom": "^29.1.1", "knip": "^6.4.1", "license-checker-rseidelsohn": "^4.4.2", "postcss": "^8.5.4", diff --git a/desktop/package.json b/desktop/package.json index 8ad7de4dc..43847028f 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -116,6 +116,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", "knip": "^6.4.1", + "jsdom": "^29.1.1", "license-checker-rseidelsohn": "^4.4.2", "postcss": "^8.5.4", "prettier": "^3.5.3", diff --git a/desktop/src/ui/components/ModelManager/ModelManager.tsx b/desktop/src/ui/components/ModelManager/ModelManager.tsx index 180ba87d6..2f46358fc 100644 --- a/desktop/src/ui/components/ModelManager/ModelManager.tsx +++ b/desktop/src/ui/components/ModelManager/ModelManager.tsx @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { Button, Flex, Stack, Text } from '@nvidia/foundations-react-core' import type { BackendInfo } from '@/ui/types/engine-info' import { EngineCapabilities } from '@/ui/constants/engine-capabilities' @@ -23,6 +23,16 @@ import type { ModelEntry } from '@/ui/types/model-hub' export function ModelManager({ backend, nodeId }: { backend: BackendInfo; nodeId: string }) { const [openModelHubModal, setOpenModelHubModal] = useState(false) const [modelPendingDelete, setModelPendingDelete] = useState(null) + const isRunning = backend.processStatus === 'running' + + // A lifecycle transition ends the interaction that opened either dialog. + // Forget it so restarting cannot reopen a stale download or delete prompt. + useEffect(() => { + if (!isRunning) { + setOpenModelHubModal(false) + setModelPendingDelete(null) + } + }, [isRunning]) const models = (backend.models ?? []).sort((a, b) => formatModelDisplayName(a.name, backend.type).localeCompare( formatModelDisplayName(b.name, backend.type) @@ -115,6 +125,7 @@ export function ModelManager({ backend, nodeId }: { backend: BackendInfo; nodeId const handleAction = useCallback( (modelName: string, action: string) => { + if (!isRunning) return switch (action) { case 'load': window.pairApi.engines.loadModel(backendType, nodeId, modelName) @@ -131,7 +142,7 @@ export function ModelManager({ backend, nodeId }: { backend: BackendInfo; nodeId break } }, - [backendType, confirmBeforeDelete, nodeId] + [backendType, confirmBeforeDelete, isRunning, nodeId] ) // `ConfirmModal` closes (`onOpenChange(false)`) before it calls `onConfirm`, @@ -139,16 +150,17 @@ export function ModelManager({ backend, nodeId }: { backend: BackendInfo; nodeId // callback closes over the value from the render that showed the modal, and // React's state update does not mutate that binding. const handleConfirmDelete = useCallback(() => { - if (modelPendingDelete) { + if (isRunning && modelPendingDelete) { window.pairApi.engines.deleteModel(backendType, nodeId, modelPendingDelete) } - }, [backendType, modelPendingDelete, nodeId]) + }, [backendType, isRunning, modelPendingDelete, nodeId]) const handleExpiryChange = useCallback( (modelName: string, value: ModelExpiry) => { + if (!isRunning) return window.pairApi.engines.setModelExpiry(backendType, nodeId, modelName, value) }, - [backendType, nodeId] + [backendType, isRunning, nodeId] ) const transientPullProgress = transientModel @@ -159,7 +171,6 @@ export function ModelManager({ backend, nodeId }: { backend: BackendInfo; nodeId () => caps?.hasModelSearchOnlyWhenRunning ?? false, [caps?.hasModelSearchOnlyWhenRunning] ) - const isRunning = useMemo(() => backend.processStatus === 'running', [backend.processStatus]) const supportsSearch = useMemo( () => caps !== undefined && (!hasModelSearchOnlyWhenRunning || isRunning), [caps, hasModelSearchOnlyWhenRunning, isRunning] @@ -167,7 +178,7 @@ export function ModelManager({ backend, nodeId }: { backend: BackendInfo; nodeId const handleDownload = useCallback( (entries: ModelEntry[]) => { - if (!entries || entries.length === 0) return + if (!isRunning || !entries || entries.length === 0) return setOpenModelHubModal(false) @@ -177,7 +188,7 @@ export function ModelManager({ backend, nodeId }: { backend: BackendInfo; nodeId } } }, - [backendType, nodeId] + [backendType, isRunning, nodeId] ) if (backend.processStatus === 'stopped') { @@ -239,7 +250,7 @@ export function ModelManager({ backend, nodeId }: { backend: BackendInfo; nodeId )} { if (!open) setModelPendingDelete(null) }} @@ -256,12 +267,13 @@ export function ModelManager({ backend, nodeId }: { backend: BackendInfo; nodeId size="small" kind="primary" color="brand" + disabled={!isRunning} onClick={() => setOpenModelHubModal(true)} > Add model diff --git a/desktop/src/ui/components/ModelManager/ModelRowHeader.tsx b/desktop/src/ui/components/ModelManager/ModelRowHeader.tsx index a0e9e79aa..eee1bf8a7 100644 --- a/desktop/src/ui/components/ModelManager/ModelRowHeader.tsx +++ b/desktop/src/ui/components/ModelManager/ModelRowHeader.tsx @@ -11,6 +11,7 @@ export function ModelRowHeader({ modelName, formattedName, isLoaded, + disabled, actionItems, actionDd, onAction @@ -18,6 +19,7 @@ export function ModelRowHeader({ modelName: string formattedName: string isLoaded: boolean + disabled: boolean actionItems: { id: string; children: string; disabled?: boolean; danger?: boolean }[] actionDd: ModelRowDropdownControl onAction: (modelName: string, action: string) => void @@ -26,11 +28,11 @@ export function ModelRowHeader({ () => actionItems.map(item => ({ children: item.children, - disabled: item.disabled, + disabled: disabled || item.disabled, danger: item.danger, onSelect: () => onAction(modelName, item.id) })), - [actionItems, modelName, onAction] + [actionItems, disabled, modelName, onAction] ) return ( @@ -53,6 +55,7 @@ export function ModelRowHeader({ {actionItems.length > 0 && ( void }) { @@ -32,11 +34,12 @@ export function ModelRowSecondaryRow({ () => EXPIRY_OPTIONS.map(item => ({ children: item.children, + disabled, onSelect: () => { if (isModelExpiry(item.id)) onExpiryChange(model.name, item.id) } })), - [model.name, onExpiryChange] + [disabled, model.name, onExpiryChange] ) return ( @@ -83,6 +86,7 @@ export function ModelRowSecondaryRow({ {hasExpiry && ( { + const container = ({ children }: { children?: ReactNode }) => + createElement('div', null, children) + return { + Flex: container, + Stack: container, + Text: container, + Divider: () => createElement('hr'), + Button: ({ children, disabled, onClick }: ButtonProps) => + createElement('button', { disabled, onClick }, children), + Dropdown: ({ items, children, disabled, 'aria-label': label }: DropdownProps) => + createElement( + 'div', + null, + createElement('button', { disabled, 'aria-label': label }, children), + items.map((item, index) => { + if (typeof item === 'string' || !('onSelect' in item)) return null + return createElement( + 'button', + { + key: index, + disabled: disabled || item.disabled, + onClick: () => item.onSelect?.(new Event('select')) + }, + item.children + ) + }) + ) + } +}) + +vi.mock('@/ui/components/ConfirmModal', () => ({ + ConfirmModal: ({ open, onOpenChange, onConfirm }: ComponentProps) => + open + ? createElement( + 'div', + { role: 'dialog', 'aria-label': 'Delete confirmation' }, + createElement( + 'button', + { + onClick: () => { + onOpenChange(false) + onConfirm() + } + }, + 'Delete and restart' + ) + ) + : null +})) + +vi.mock('@/ui/components/ModelHub/ModelHubModal', () => ({ + ModelHubModal: ({ engine, onSubmit }: ComponentProps) => + engine + ? createElement( + 'div', + { role: 'dialog', 'aria-label': 'Model catalog' }, + createElement( + 'button', + { + onClick: () => + onSubmit([ + { + id: 'org/new-model', + name: 'org/new-model', + author: 'org', + url: 'https://example.invalid/model', + updatedAt: new Date('2026-01-01') + } + ]) + }, + 'Download selected model' + ) + ) + : null +})) + +const commands = { + loadModel: vi.fn(), + unloadModel: vi.fn(), + deleteModel: vi.fn(), + pullModel: vi.fn(), + setModelExpiry: vi.fn() +} +let host: HTMLDivElement +let root: Root + +beforeEach(() => { + Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { + configurable: true, + value: true + }) + Object.defineProperty(window, 'pairApi', { configurable: true, value: { engines: commands } }) + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) +}) + +afterEach(async () => { + await act(() => root.unmount()) + host.remove() + useEngineProgressStore.setState({ progress: new Map() }) + usePendingActionsStore.setState({ pending: new Map() }) + Reflect.deleteProperty(window, 'pairApi') + Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT') +}) + +function backend( + type: EngineType, + processStatus: EngineProcessStatus, + status: ModelItemStatus +): BackendInfo { + return { + type, + displayName: EngineDisplayNames[type], + source: 'installed', + processStatus, + port: 8001, + proxyPort: 8000, + modelsKnown: true, + models: [ + { + name: 'org/cached-model', + size: 1024, + downloaded: true, + status, + parameterSize: '1B', + quantization: '', + family: '', + digest: '', + sizeVram: null, + expiresAt: null, + expiry: '10m', + capabilities: [] + } + ] + } +} + +async function render( + type: EngineType, + processStatus: EngineProcessStatus, + status: ModelItemStatus = 'idle' +) { + await act(() => + root.render( + createElement(ModelManager, { + backend: backend(type, processStatus, status), + nodeId: 'test-node' + }) + ) + ) +} + +function button(label: string): HTMLButtonElement { + const found = Array.from(host.querySelectorAll('button')).find(b => b.textContent === label) + if (!found) throw new Error(`Missing button: ${label}`) + return found +} + +async function click(label: string) { + await act(() => button(label).click()) +} + +describe.each(EngineTypes)('%s model lifecycle controls', engine => { + it.each(['stopped', 'stopping'])( + 'forgets an open catalog after %s, so restarting does not reopen it', + async status => { + await render(engine, 'running') + await click('Add model') + expect(host.querySelector('[aria-label="Model catalog"]')).not.toBeNull() + + await render(engine, status) + expect(host.querySelector('[role="dialog"]')).toBeNull() + await render(engine, 'running') + + expect(host.querySelector('[role="dialog"]')).toBeNull() + await click('Add model') + expect(host.querySelector('[aria-label="Model catalog"]')).not.toBeNull() + } + ) + + it.each(['starting', 'stopping', 'installing', 'uninstalling'])( + 'disables model controls during %s without sending commands', + async status => { + await render(engine, status) + const controls = Array.from(host.querySelectorAll('button')) + expect(controls.length).toBeGreaterThan(0) + expect(controls.every(control => control.disabled)).toBe(true) + + await act(() => controls.forEach(control => control.click())) + for (const command of Object.values(commands)) expect(command).not.toHaveBeenCalled() + expect(host.querySelector('[role="dialog"]')).toBeNull() + } + ) + + it('reenables Load after starting and sends the selected model', async () => { + await render(engine, 'starting') + await render(engine, 'running') + expect(button('Load').disabled).toBe(false) + await click('Load') + expect(commands.loadModel).toHaveBeenCalledExactlyOnceWith( + engine, + 'test-node', + 'org/cached-model' + ) + }) + + it('keeps Eject available only for a loaded model on a running engine', async () => { + await render(engine, 'stopping', 'loaded') + expect(button('Eject').disabled).toBe(true) + await render(engine, 'running', 'loaded') + expect(button('Eject').disabled).toBe(false) + expect(button('Load').disabled).toBe(true) + await click('Eject') + expect(commands.unloadModel).toHaveBeenCalledExactlyOnceWith( + engine, + 'test-node', + 'org/cached-model' + ) + }) + + it('downloads a selected model while running', async () => { + await render(engine, 'running') + await click('Add model') + await click('Download selected model') + expect(commands.pullModel).toHaveBeenCalledExactlyOnceWith( + engine, + 'test-node', + 'org/new-model' + ) + expect(host.querySelector('[role="dialog"]')).toBeNull() + }) +}) + +describe('LM Studio delete confirmation', () => { + it.each(['stopped', 'stopping'])( + 'forgets a pending deletion after %s', + async status => { + await render('lm-studio', 'running') + await click('Delete') + expect(host.querySelector('[aria-label="Delete confirmation"]')).not.toBeNull() + + await render('lm-studio', status) + await render('lm-studio', 'running') + + expect(host.querySelector('[role="dialog"]')).toBeNull() + expect(commands.deleteModel).not.toHaveBeenCalled() + } + ) + + it('still deletes the chosen model after explicit confirmation', async () => { + await render('lm-studio', 'running') + await click('Delete') + expect(commands.deleteModel).not.toHaveBeenCalled() + await click('Delete and restart') + expect(commands.deleteModel).toHaveBeenCalledExactlyOnceWith( + 'lm-studio', + 'test-node', + 'org/cached-model' + ) + expect(host.querySelector('[role="dialog"]')).toBeNull() + }) +}) + +describe.each(['ollama', 'llama-cpp', 'vllm'])('%s direct deletion', engine => { + it('deletes a downloaded model without a restart confirmation', async () => { + await render(engine, 'running') + await click('Delete') + expect(commands.deleteModel).toHaveBeenCalledExactlyOnceWith( + engine, + 'test-node', + 'org/cached-model' + ) + expect(host.querySelector('[role="dialog"]')).toBeNull() + }) +}) + +describe('vLLM model controls', () => { + it('keeps Load and Add model available with an unloaded model while running', async () => { + await render('vllm', 'running') + expect(button('Load').disabled).toBe(false) + expect(button('Add model').disabled).toBe(false) + await click('Load') + expect(commands.loadModel).toHaveBeenCalledExactlyOnceWith( + 'vllm', + 'test-node', + 'org/cached-model' + ) + }) + + it('does not offer idle-timeout editing removed by the engine', async () => { + await render('vllm', 'stopping') + expect(host.textContent).not.toContain('Expires') + await render('vllm', 'running') + expect(host.textContent).not.toContain('Expires') + expect(commands.setModelExpiry).not.toHaveBeenCalled() + }) + + it('continues to prevent deletion of the loaded model', async () => { + await render('vllm', 'running', 'loaded') + expect(button('Delete').disabled).toBe(true) + await click('Delete') + expect(commands.deleteModel).not.toHaveBeenCalled() + }) +}) diff --git a/desktop/tests/modular/model-manager-visibility.test.ts b/desktop/tests/modular/model-manager-visibility.test.ts index 28a3c5ac8..73e28f1cd 100644 --- a/desktop/tests/modular/model-manager-visibility.test.ts +++ b/desktop/tests/modular/model-manager-visibility.test.ts @@ -26,7 +26,7 @@ function cachedModel(): ModelItem { digest: '', sizeVram: null, expiresAt: null, - expiry: '5m', + expiry: '10m', capabilities: [] } } @@ -40,6 +40,7 @@ function engineInfo(type: EngineType, overrides: Partial = {}): Bac port: 8001, proxyPort: 8000, models: [], + modelsKnown: true, ...overrides } } @@ -50,6 +51,18 @@ afterEach(() => { }) describe.each(EngineTypes)('%s model visibility', engineType => { + it('shows unavailable guidance for an unknown running inventory', () => { + const html = renderToStaticMarkup( + createElement(ModelManager, { + backend: engineInfo(engineType, { processStatus: 'running', modelsKnown: false }), + nodeId: 'local-node' + }) + ) + + expect(html).toContain('Downloaded models unavailable') + expect(html).not.toContain('No model/weight files downloaded') + }) + it.each(['local-node', 'peer-node'])('shows the start message when stopped on %s', nodeId => { const html = renderToStaticMarkup( createElement(ModelManager, { backend: engineInfo(engineType), nodeId }) @@ -116,12 +129,11 @@ describe.each(EngineTypes)('%s model visibility', engineType => { }) describe.each(['local-node', 'peer-node'])('vLLM state details on %s', nodeId => { - it('keeps models and controls visible when the engine is on but idle', () => { + it('keeps models and controls visible when running with an unloaded model', () => { const html = renderToStaticMarkup( createElement(ModelManager, { backend: engineInfo('vllm', { processStatus: 'running', - idle: true, models: [cachedModel()] }), nodeId @@ -129,15 +141,15 @@ describe.each(['local-node', 'peer-node'])('vLLM state details on %s', nodeId => ) expect(html).toContain(cachedModel().name) - expect(html).toContain('Unloads after 5m idle') + expect(html).not.toContain('Unloads after') expect(html).toContain('Add model') expect(html).not.toContain('Start engine to see models') }) - it('shows an empty inventory when on but idle with no cached models', () => { + it('shows an empty inventory when running with no cached models', () => { const html = renderToStaticMarkup( createElement(ModelManager, { - backend: engineInfo('vllm', { processStatus: 'running', idle: true }), + backend: engineInfo('vllm', { processStatus: 'running' }), nodeId }) )