Skip to content

Commit 0e2ed5e

Browse files
feat(title-bar): make download starts + finishes obvious in the tray (#558) (#562)
* feat(title-bar): make download starts + finishes obvious in the tray (#558) When downloads start the tray now flashes once and the badge pulses while in flight (subtle accent ring + scale bounce). When everything finishes and the user hasn't opened the popup yet, the tray switches to a success-coloured has-unseen state with a Check + count badge so the completion stays visible until they look. Opening the downloads popup acknowledges all finished entries and the tray returns to its idle state. Pure title-bar renderer change. Wires through useTitleBarMenus' existing onDownloadsChanged stream + onMenuOpened({menu:'downloads'}). Honours prefers-reduced-motion. First downloads-changed push is treated as already-acknowledged so a window opening mid-flow doesn't paint a stale unseen indicator. Amp-Thread-ID: https://ampcode.com/threads/T-019e2e09-dcf6-75eb-b31a-b9a9825fc315 Co-authored-by: Amp <amp@ampcode.com> * feat(downloads): order downloads newest-first across popup + settings Flips the createdAt sort in both DownloadsView surfaces so the most recently kicked-off download surfaces at the top of the list. Active and terminal entries still share one slot so a download that transitions completed / cancelled keeps its position rather than jumping buckets. Amp-Thread-ID: https://ampcode.com/threads/T-019e2e09-dcf6-75eb-b31a-b9a9825fc315 Co-authored-by: Amp <amp@ampcode.com> --------- Co-authored-by: Amp <amp@ampcode.com>
1 parent 7dfd109 commit 0e2ed5e

6 files changed

Lines changed: 365 additions & 25 deletions

File tree

src/renderer/src/comfyTitleBar/TitleBarApp.test.ts

Lines changed: 116 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -648,11 +648,13 @@ describe('TitleBarApp', () => {
648648
expect(tray.attributes('aria-label')).toBe('2 downloads in progress')
649649
})
650650

651-
it('renders the downloads tray icon-only (no badge) when only recent entries exist', async () => {
652-
// The badge counts ACTIVE downloads, not recent ones — so a tray
653-
// with only completed entries should still show the icon (so the
654-
// user can reopen the popover) but without a numeric badge that
655-
// would otherwise read as "things still working".
651+
it('treats recent entries already present on the first push as already-acknowledged', async () => {
652+
// The first downloads-changed push is the initial state main
653+
// hands the title bar after `ready()`. Anything `recent` there
654+
// finished before this window even opened, so we suppress the
655+
// unseen-finished indicator (it would otherwise misfire on every
656+
// window mount). The tray collapses back to its idle label and
657+
// shows neither a numeric nor an unseen badge.
656658
const { default: TitleBarApp } = await import('./TitleBarApp.vue')
657659
const wrapper = mount(TitleBarApp)
658660
await flushPromises()
@@ -673,12 +675,121 @@ describe('TitleBarApp', () => {
673675
await flushPromises()
674676
expect(wrapper.find('.title-downloads-tray').exists()).toBe(true)
675677
expect(wrapper.find('.title-downloads-badge').exists()).toBe(false)
678+
expect(wrapper.find('.title-downloads-tray').classes()).not.toContain('has-unseen')
676679
// Idle label — no in-flight downloads, but the tray is still
677680
// reachable so the recent-completed row in the popover stays
678681
// accessible until the user dismisses it.
679682
expect(wrapper.find('.title-downloads-tray').attributes('title')).toBe('Downloads')
680683
})
681684

685+
it('marks the tray as unseen when a download completes after the initial state', async () => {
686+
// Simulate the real flow: the window opens with nothing in flight
687+
// (initial empty push), then a download starts and finishes. The
688+
// user never opened the popup, so the tray should switch to its
689+
// success-coloured unseen state with a labelled badge.
690+
const { default: TitleBarApp } = await import('./TitleBarApp.vue')
691+
const wrapper = mount(TitleBarApp)
692+
await flushPromises()
693+
bridgeState.downloadsChangedCallbacks.forEach((cb) =>
694+
cb({ active: [], recent: [] }),
695+
)
696+
await flushPromises()
697+
bridgeState.downloadsChangedCallbacks.forEach((cb) =>
698+
cb({
699+
active: [
700+
{
701+
url: 'https://example.com/a.safetensors',
702+
filename: 'a.safetensors',
703+
progress: 0.4,
704+
status: 'downloading',
705+
},
706+
],
707+
recent: [],
708+
}),
709+
)
710+
await flushPromises()
711+
bridgeState.downloadsChangedCallbacks.forEach((cb) =>
712+
cb({
713+
active: [],
714+
recent: [
715+
{
716+
url: 'https://example.com/a.safetensors',
717+
filename: 'a.safetensors',
718+
progress: 1,
719+
status: 'completed',
720+
},
721+
],
722+
}),
723+
)
724+
await flushPromises()
725+
const tray = wrapper.find('.title-downloads-tray')
726+
expect(tray.classes()).toContain('has-unseen')
727+
expect(tray.classes()).not.toContain('has-active')
728+
const badge = wrapper.find('.title-downloads-badge.is-unseen')
729+
expect(badge.exists()).toBe(true)
730+
expect(badge.text()).toBe('1')
731+
expect(tray.attributes('title')).toBe('1 download finished — click to review')
732+
})
733+
734+
it('clears the unseen indicator when the downloads popup is opened', async () => {
735+
const { default: TitleBarApp } = await import('./TitleBarApp.vue')
736+
const wrapper = mount(TitleBarApp)
737+
await flushPromises()
738+
bridgeState.downloadsChangedCallbacks.forEach((cb) =>
739+
cb({ active: [], recent: [] }),
740+
)
741+
await flushPromises()
742+
bridgeState.downloadsChangedCallbacks.forEach((cb) =>
743+
cb({
744+
active: [],
745+
recent: [
746+
{
747+
url: 'https://example.com/a.safetensors',
748+
filename: 'a.safetensors',
749+
progress: 1,
750+
status: 'completed',
751+
},
752+
],
753+
}),
754+
)
755+
await flushPromises()
756+
expect(wrapper.find('.title-downloads-tray').classes()).toContain('has-unseen')
757+
bridgeState.menuOpenedCallbacks.forEach((cb) =>
758+
cb({ menu: 'downloads' } as { menu: 'menu' }),
759+
)
760+
await flushPromises()
761+
const tray = wrapper.find('.title-downloads-tray')
762+
expect(tray.classes()).not.toContain('has-unseen')
763+
expect(wrapper.find('.title-downloads-badge.is-unseen').exists()).toBe(false)
764+
expect(tray.attributes('title')).toBe('Downloads')
765+
})
766+
767+
it('flashes the tray when a brand-new active download appears', async () => {
768+
const { default: TitleBarApp } = await import('./TitleBarApp.vue')
769+
const wrapper = mount(TitleBarApp)
770+
await flushPromises()
771+
// Initial empty state so the next push counts as a real new arrival.
772+
bridgeState.downloadsChangedCallbacks.forEach((cb) =>
773+
cb({ active: [], recent: [] }),
774+
)
775+
await flushPromises()
776+
bridgeState.downloadsChangedCallbacks.forEach((cb) =>
777+
cb({
778+
active: [
779+
{
780+
url: 'https://example.com/a.safetensors',
781+
filename: 'a.safetensors',
782+
progress: 0.1,
783+
status: 'pending',
784+
},
785+
],
786+
recent: [],
787+
}),
788+
)
789+
await flushPromises()
790+
expect(wrapper.find('.title-downloads-tray').classes()).toContain('is-flashing')
791+
})
792+
682793
it('uses singular copy when exactly one download is in flight', async () => {
683794
const { default: TitleBarApp } = await import('./TitleBarApp.vue')
684795
const wrapper = mount(TitleBarApp)

src/renderer/src/comfyTitleBar/TitleBarApp.vue

Lines changed: 147 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
<script setup lang="ts">
2-
import { ref, onMounted, onUnmounted, useTemplateRef } from 'vue'
2+
import { ref, onMounted, onUnmounted, useTemplateRef, watch } from 'vue'
33
import { useI18n } from 'vue-i18n'
44
import {
55
ArrowDownToLine,
6+
Check,
67
Download,
78
Loader2,
89
Menu as MenuIcon,
@@ -222,7 +223,9 @@ const { isHoverActive } = useTitleBarHoverGate({ hideTip, handleTooltipPointer }
222223
223224
const {
224225
downloadsActiveCount,
226+
unseenFinishedCount,
225227
downloadsTrayLabel,
228+
downloadsStartedAt,
226229
handleFileMenu,
227230
handleDownloadsTray,
228231
} = useTitleBarMenus({
@@ -232,6 +235,25 @@ const {
232235
downloadsBtnRef,
233236
})
234237
238+
/** One-shot "downloads started" attention flash. Driven by
239+
* `downloadsStartedAt` from the menus composable, which bumps each
240+
* time a brand-new active download appears. The flash is purely
241+
* decorative — it overlays the existing pulsing badge / count so the
242+
* user immediately notices the tray came alive even if they were
243+
* looking elsewhere on screen. The 1600 ms window matches one full
244+
* cycle of the underlying CSS keyframes. */
245+
const downloadsFlash = ref(false)
246+
let flashTimer: ReturnType<typeof setTimeout> | null = null
247+
watch(downloadsStartedAt, (next) => {
248+
if (next === 0) return
249+
downloadsFlash.value = true
250+
if (flashTimer) clearTimeout(flashTimer)
251+
flashTimer = setTimeout(() => {
252+
downloadsFlash.value = false
253+
flashTimer = null
254+
}, 1600)
255+
})
256+
235257
let unsubPanel: (() => void) | undefined
236258
237259
onMounted(() => {
@@ -245,6 +267,10 @@ onMounted(() => {
245267
onUnmounted(() => {
246268
unsubPanel?.()
247269
hideTip()
270+
if (flashTimer) {
271+
clearTimeout(flashTimer)
272+
flashTimer = null
273+
}
248274
})
249275
</script>
250276

@@ -333,16 +359,38 @@ onUnmounted(() => {
333359
ref="downloadsBtn"
334360
type="button"
335361
class="title-downloads-tray"
336-
:class="{ 'has-active': downloadsActiveCount > 0 }"
362+
:class="{
363+
'has-active': downloadsActiveCount > 0,
364+
'has-unseen': downloadsActiveCount === 0 && unseenFinishedCount > 0,
365+
'is-flashing': downloadsFlash,
366+
}"
337367
v-bind="tooltipAttrs(downloadsTrayLabel)"
338368
@click="handleDownloadsTray"
339369
>
340370
<ArrowDownToLine :size="14" />
371+
<!-- Active queue badge: count of in-flight downloads. Pulses
372+
via CSS while `.has-active`; the one-shot `.is-flashing`
373+
class layers a brighter scale-bounce on top whenever a
374+
brand-new download appears so the user catches the
375+
"started" event even if they were looking elsewhere. -->
341376
<span
342377
v-if="downloadsActiveCount > 0"
343378
class="title-downloads-badge"
344379
aria-hidden="true"
345380
>{{ downloadsActiveCount }}</span>
381+
<!-- Unseen-finished badge (issue #558): only shown when the
382+
queue is idle AND something terminal landed since the
383+
user last opened the popup. Distinct success colour +
384+
check icon so it doesn't read as "still working". Cleared
385+
on the next `onMenuOpened({menu:'downloads'})` push. -->
386+
<span
387+
v-else-if="unseenFinishedCount > 0"
388+
class="title-downloads-badge is-unseen"
389+
aria-hidden="true"
390+
>
391+
<Check :size="9" :stroke-width="3" />
392+
<span class="title-downloads-badge-count">{{ unseenFinishedCount }}</span>
393+
</span>
346394
</button>
347395
<!-- Center identity pill. Install-backed hosts show the install's
348396
name + install-type icon; install-less hosts show the static
@@ -701,6 +749,103 @@ onUnmounted(() => {
701749
font-size: 10px;
702750
font-weight: 600;
703751
line-height: 1;
752+
gap: 2px;
753+
}
754+
755+
/* Active-queue attention treatment (issue #558).
756+
While downloads are in flight the tray shouldn't read as the same
757+
passive surface chip it has in the steady state — slow ring pulse
758+
+ a subtle accent-tinted border draws the eye without being noisy
759+
enough to feel like an alert. */
760+
.title-downloads-tray.has-active {
761+
border-color: rgba(96, 165, 250, 0.55);
762+
background: rgba(96, 165, 250, 0.16);
763+
}
764+
.title-bar.is-light .title-downloads-tray.has-active {
765+
border-color: rgba(37, 99, 235, 0.55);
766+
background: rgba(37, 99, 235, 0.12);
767+
}
768+
.title-downloads-tray.has-active .title-downloads-badge {
769+
animation: title-downloads-pulse 1.6s ease-in-out infinite;
770+
}
771+
772+
/* One-shot "downloads started" flash. Layered on top of `.has-active`
773+
so a fresh download that arrives while others are running still
774+
triggers a noticeable bump rather than blending into the existing
775+
pulse. The class is owned by the renderer (set for ~1.6 s after
776+
`downloadsStartedAt` bumps) so the animation re-runs cleanly each
777+
time. */
778+
.title-downloads-tray.is-flashing .title-downloads-badge {
779+
animation:
780+
title-downloads-flash 0.55s cubic-bezier(0.2, 0.9, 0.3, 1.4) 1,
781+
title-downloads-pulse 1.6s ease-in-out infinite 0.6s;
782+
}
783+
.title-downloads-tray.is-flashing {
784+
animation: title-downloads-tray-flash 0.9s ease-out 1;
785+
}
786+
787+
/* Unseen-finished treatment. Distinct success-coloured chrome so
788+
"downloads completed while you weren't looking" reads as a
789+
different state than "downloads in flight". Cleared the moment the
790+
user opens the popup. */
791+
.title-downloads-tray.has-unseen {
792+
border-color: rgba(34, 197, 94, 0.55);
793+
background: rgba(34, 197, 94, 0.16);
794+
}
795+
.title-bar.is-light .title-downloads-tray.has-unseen {
796+
border-color: rgba(22, 163, 74, 0.55);
797+
background: rgba(22, 163, 74, 0.12);
798+
}
799+
.title-downloads-badge.is-unseen {
800+
background: #22c55e;
801+
padding: 0 4px;
802+
}
803+
.title-downloads-badge.is-unseen .title-downloads-badge-count {
804+
font-size: 10px;
805+
font-weight: 600;
806+
line-height: 1;
807+
}
808+
809+
@keyframes title-downloads-pulse {
810+
0%, 100% {
811+
box-shadow: 0 0 0 0 rgba(96, 165, 250, 0.55);
812+
}
813+
50% {
814+
box-shadow: 0 0 0 4px rgba(96, 165, 250, 0);
815+
}
816+
}
817+
@keyframes title-downloads-flash {
818+
0% {
819+
transform: scale(0.6);
820+
opacity: 0;
821+
}
822+
60% {
823+
transform: scale(1.25);
824+
opacity: 1;
825+
}
826+
100% {
827+
transform: scale(1);
828+
opacity: 1;
829+
}
830+
}
831+
@keyframes title-downloads-tray-flash {
832+
0%, 100% {
833+
box-shadow: 0 0 0 0 rgba(96, 165, 250, 0);
834+
}
835+
20% {
836+
box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.55);
837+
}
838+
}
839+
840+
/* Honour reduced-motion preferences — fall back to a static accent
841+
border for the running state and skip the bounce / pulse loops
842+
entirely. */
843+
@media (prefers-reduced-motion: reduce) {
844+
.title-downloads-tray.has-active .title-downloads-badge,
845+
.title-downloads-tray.is-flashing,
846+
.title-downloads-tray.is-flashing .title-downloads-badge {
847+
animation: none;
848+
}
704849
}
705850
706851
/* Dropdown popups are now native OS menus rendered via Menu.popup() in

0 commit comments

Comments
 (0)