feat: add model download progress dialog (#7897)

## Summary

Add a progress dialog for model downloads that appears when downloads
are active.

## Changes

- Add `ModelImportProgressDialog` component for showing download
progress
- Add `ProgressToastItem` component for individual download job display
- Add `StatusBadge` component for status indicators
- Extend `assetDownloadStore` with:
  - `finishedDownloads` computed for completed/failed jobs
  - `hasDownloads` computed for dialog visibility
  - `clearFinishedDownloads()` to dismiss finished downloads
- Dialog visibility driven by store state
- Closing dialog clears finished downloads
- Filter dropdown to show all/completed/failed downloads
- Expandable/collapsible UI with animated transitions
- Update AGENTS.md with import type convention and pluralization note

## Testing

- Start a model download and verify the dialog appears
- Verify expand/collapse animation works
- Verify filter dropdown works
- Verify closing the dialog clears finished downloads
- Verify dialog hides when no downloads remain

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7897-feat-add-model-download-progress-dialog-2e26d73d36508116960eff6fbe7dc392)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Alexander Brown
2026-01-08 14:29:02 -08:00
committed by GitHub
parent 0ca27f3d9b
commit 405e756d4c
7 changed files with 413 additions and 65 deletions

View File

@@ -0,0 +1,271 @@
<script setup lang="ts">
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ProgressToastItem from '@/components/toast/ProgressToastItem.vue'
import Button from '@/components/ui/button/Button.vue'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const assetDownloadStore = useAssetDownloadStore()
const visible = computed(() => assetDownloadStore.hasDownloads)
const isExpanded = ref(false)
const activeFilter = ref<'all' | 'completed' | 'failed'>('all')
const filterPopoverRef = ref<InstanceType<typeof Popover> | null>(null)
function toggle() {
isExpanded.value = !isExpanded.value
if (!isExpanded.value) {
filterPopoverRef.value?.hide()
}
}
const filterOptions = [
{ value: 'all', label: 'all' },
{ value: 'completed', label: 'completed' },
{ value: 'failed', label: 'failed' }
] as const
function onFilterClick(event: Event) {
filterPopoverRef.value?.toggle(event)
}
function setFilter(filter: typeof activeFilter.value) {
activeFilter.value = filter
filterPopoverRef.value?.hide()
}
const downloadJobs = computed(() => assetDownloadStore.downloadList)
const completedJobs = computed(() =>
assetDownloadStore.finishedDownloads.filter((d) => d.status === 'completed')
)
const failedJobs = computed(() =>
assetDownloadStore.finishedDownloads.filter((d) => d.status === 'failed')
)
const isInProgress = computed(() => assetDownloadStore.hasActiveDownloads)
const currentJobName = computed(() => {
const activeJob = downloadJobs.value.find((job) => job.status === 'running')
return activeJob?.assetName || t('progressToast.downloadingModel')
})
const completedCount = computed(
() => completedJobs.value.length + failedJobs.value.length
)
const totalCount = computed(() => downloadJobs.value.length)
const filteredJobs = computed(() => {
switch (activeFilter.value) {
case 'completed':
return completedJobs.value
case 'failed':
return failedJobs.value
default:
return downloadJobs.value
}
})
const activeFilterLabel = computed(() => {
const option = filterOptions.find((f) => f.value === activeFilter.value)
return option
? t(`progressToast.filter.${option.label}`)
: t('progressToast.filter.all')
})
function closeDialog() {
assetDownloadStore.clearFinishedDownloads()
isExpanded.value = false
}
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="translate-y-full opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-full opacity-0"
>
<div
v-if="visible"
class="fixed inset-x-0 bottom-6 z-50 mx-auto w-[80%] max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg"
>
<div
:class="
cn(
'overflow-hidden transition-all duration-300',
isExpanded ? 'max-h-[400px]' : 'max-h-0'
)
"
>
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h3 class="text-sm font-bold text-base-foreground">
{{ t('progressToast.importingModels') }}
</h3>
<div class="flex items-center gap-2">
<Button
variant="secondary"
size="md"
class="gap-1.5 px-2"
@click="onFilterClick"
>
<i class="icon-[lucide--list-filter] size-4" />
<span>{{ activeFilterLabel }}</span>
<i class="icon-[lucide--chevron-down] size-3" />
</Button>
<Popover
ref="filterPopoverRef"
append-to="body"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: { class: 'absolute z-50' },
content: {
class:
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg'
}
}"
>
<div
class="flex min-w-[120px] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
>
<Button
v-for="option in filterOptions"
:key="option.value"
variant="textonly"
size="sm"
:class="
cn(
'w-full justify-start bg-transparent',
activeFilter === option.value &&
'bg-secondary-background-selected'
)
"
@click="setFilter(option.value)"
>
{{ t(`progressToast.filter.${option.label}`) }}
</Button>
</div>
</Popover>
</div>
</div>
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
<div
v-if="filteredJobs.length > 3"
class="absolute right-1 top-4 h-12 w-1 rounded-full bg-muted-foreground"
/>
<div class="flex flex-col gap-2">
<ProgressToastItem
v-for="job in filteredJobs"
:key="job.taskId"
:job="job"
/>
</div>
<div
v-if="filteredJobs.length === 0"
class="flex flex-col items-center justify-center py-6 text-center"
>
<span class="text-sm text-muted-foreground">
{{
t('progressToast.noImportsInQueue', {
filter: activeFilterLabel
})
}}
</span>
</div>
</div>
</div>
<div
class="flex h-12 items-center justify-between border-t border-border-default px-4"
>
<div class="flex items-center gap-2 text-sm">
<template v-if="isInProgress">
<i
class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground"
/>
<span class="font-bold text-base-foreground">{{
currentJobName
}}</span>
</template>
<template v-else-if="failedJobs.length > 0">
<i
class="icon-[lucide--circle-alert] size-4 text-destructive-background"
/>
<span class="font-bold text-base-foreground">
{{
t('progressToast.downloadsFailed', {
count: failedJobs.length
})
}}
</span>
</template>
<template v-else>
<i class="icon-[lucide--check-circle] size-4 text-jade-600" />
<span class="font-bold text-base-foreground">
{{ t('progressToast.allDownloadsCompleted') }}
</span>
</template>
</div>
<div class="flex items-center gap-2">
<span v-if="isInProgress" class="text-sm text-muted-foreground">
{{
t('progressToast.progressCount', {
completed: completedCount,
total: totalCount
})
}}
</span>
<div class="flex items-center">
<Button
variant="muted-textonly"
size="icon"
:aria-label="
isExpanded
? t('contextMenu.Collapse')
: t('contextMenu.Expand')
"
@click.stop="toggle"
>
<i
:class="
cn(
'size-4',
isExpanded
? 'icon-[lucide--chevron-down]'
: 'icon-[lucide--chevron-up]'
)
"
/>
</Button>
<Button
v-if="!isInProgress"
variant="muted-textonly"
size="icon"
:aria-label="t('g.close')"
@click.stop="closeDialog"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>