Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
55 changes: 31 additions & 24 deletions desktop/src/ui/components/ModelManager/ModelManager.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<string | null>(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)
Expand Down Expand Up @@ -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)
Expand All @@ -131,24 +142,25 @@ export function ModelManager({ backend, nodeId }: { backend: BackendInfo; nodeId
break
}
},
[backendType, confirmBeforeDelete, nodeId]
[backendType, confirmBeforeDelete, isRunning, nodeId]
)

// `ConfirmModal` closes (`onOpenChange(false)`) before it calls `onConfirm`,
// which clears `modelPendingDelete`. This still reads the right model: the
// 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
Expand All @@ -159,19 +171,14 @@ 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),
[caps, hasModelSearchOnlyWhenRunning, isRunning]
)

const handleDownload = useCallback(
(entries: ModelEntry[]) => {
if (!entries || entries.length === 0) return
if (!isRunning || !entries || entries.length === 0) return

setOpenModelHubModal(false)

Expand All @@ -181,9 +188,17 @@ export function ModelManager({ backend, nodeId }: { backend: BackendInfo; nodeId
}
}
},
[backendType, nodeId]
[backendType, isRunning, nodeId]
)

if (backend.processStatus === 'stopped') {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we clear the modal state when the engine becomes stopped? This early return unmounts ModelHubModal and ConfirmModal but leaves openModelHubModal and modelPendingDelete set. If the engine starts again, the model hub—or a stale delete confirmation—could reappear. Consider resetting those states on stop and adding a stopped→running regression case.

return (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard handles only the exact stopped status. While the engine is stopping, model rows and controls still render; for vLLM, modelOpsWhenStopped may also leave Load enabled because isRunning is false. Could we explicitly distinguish stopped from transitional states and disable actions while stopping, while preserving the intended actions for a fully stopped vLLM engine? A stopping-state regression test would clarify the expected behavior.

<Text kind="body/regular/sm" className="text-subtle-color pl-2">
Start engine to see models
</Text>
)
}

return (
<Stack gap="4">
{transientModel && (
Expand All @@ -200,7 +215,7 @@ export function ModelManager({ backend, nodeId }: { backend: BackendInfo; nodeId

{!isBusy && (
<>
{(isRunning || modelOpsWhenStopped) &&
{isRunning &&
models.length === 0 &&
incomingSyncs.length === 0 &&
(backend.modelsKnown ? (
Expand All @@ -212,15 +227,6 @@ export function ModelManager({ backend, nodeId }: { backend: BackendInfo; nodeId
Downloaded models unavailable
</Text>
))}
{!modelOpsWhenStopped &&
models.length === 0 &&
incomingSyncs.length === 0 &&
backend.processStatus === 'stopped' && (
<Text kind="body/regular/sm" className="text-subtle-color pl-2">
Start engine to see models
</Text>
)}

{models.length > 0 && (
<Stack gap="1">
{models.map(model => (
Expand All @@ -244,7 +250,7 @@ export function ModelManager({ backend, nodeId }: { backend: BackendInfo; nodeId
)}

<ConfirmModal
open={modelPendingDelete !== null}
open={isRunning && modelPendingDelete !== null}
onOpenChange={open => {
if (!open) setModelPendingDelete(null)
}}
Expand All @@ -261,12 +267,13 @@ export function ModelManager({ backend, nodeId }: { backend: BackendInfo; nodeId
size="small"
kind="primary"
color="brand"
disabled={!isRunning}
onClick={() => setOpenModelHubModal(true)}
>
Add model
</Button>
<ModelHubModal
engine={openModelHubModal ? backend.type : null}
engine={isRunning && openModelHubModal ? backend.type : null}
onOpenChange={setOpenModelHubModal}
onSubmit={handleDownload}
downloadedModels={models}
Expand Down
11 changes: 4 additions & 7 deletions desktop/src/ui/components/ModelManager/ModelRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,11 @@ function ModelRowInner({
const items: { id: string; children: string; disabled?: boolean; danger?: boolean }[] = []
// A command is already in flight for this model -- lock the whole menu so
// the user cannot fire a second conflicting op before it resolves.
const busy = pendingAction !== undefined
const busy = !isRunning || pendingAction !== undefined
items.push({
id: 'load',
children: 'Load',
disabled:
busy ||
isLoaded ||
!model.downloaded ||
(!isRunning && !capabilities.modelOpsWhenStopped)
disabled: busy || isLoaded || !model.downloaded
})
if (capabilities.hasEject) {
items.push({
Expand All @@ -79,7 +75,6 @@ function ModelRowInner({
capabilities.hasEject,
capabilities.hasDeleteModel,
capabilities.deleteBlockedWhileLoaded,
capabilities.modelOpsWhenStopped,
isLoaded,
isRunning,
model.downloaded,
Expand All @@ -99,6 +94,7 @@ function ModelRowInner({
modelName={model.name}
formattedName={formattedName}
isLoaded={isLoaded}
disabled={!isRunning}
actionItems={actionItems}
actionDd={actionDd}
onAction={onAction}
Expand All @@ -108,6 +104,7 @@ function ModelRowInner({
progress={progress}
busyLabel={busyLabel}
hasExpiry={capabilities.hasExpiry}
disabled={!isRunning}
expiryDd={expiryDd}
onExpiryChange={onExpiryChange}
/>
Expand Down
7 changes: 5 additions & 2 deletions desktop/src/ui/components/ModelManager/ModelRowHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ export function ModelRowHeader({
modelName,
formattedName,
isLoaded,
disabled,
actionItems,
actionDd,
onAction
}: {
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
Expand All @@ -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 (
Expand All @@ -53,6 +55,7 @@ export function ModelRowHeader({
{actionItems.length > 0 && (
<Dropdown
items={dropdownItems}
disabled={disabled}
showChevron={false}
size="small"
className="compact-dropdown shrink-0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export function ModelRowSecondaryRow({
progress,
busyLabel,
hasExpiry,
disabled,
expiryDd,
onExpiryChange
}: {
Expand All @@ -25,18 +26,20 @@ export function ModelRowSecondaryRow({
/** Optimistic "working" label (load/eject/delete in flight) shown with a spinner. */
busyLabel?: string | null
hasExpiry: boolean
disabled: boolean
expiryDd: ModelRowDropdownControl
onExpiryChange: (modelName: string, value: ModelExpiry) => void
}) {
const expiryItems: DropdownEntry[] = useMemo(
() =>
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 (
Expand Down Expand Up @@ -83,6 +86,7 @@ export function ModelRowSecondaryRow({
{hasExpiry && (
<Dropdown
items={expiryItems}
disabled={disabled}
size="small"
className="compact-dropdown"
onOpenChange={expiryDd.onOpenChange}
Expand Down
Loading
Loading