From cc048464fa23351f4952a9ff84566422920e5413 Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Mon, 15 Jun 2026 14:36:26 -0700 Subject: [PATCH 1/9] ci: add team-gated Cursor review (thin caller for github-workflows) Calls the reusable Comfy-Org/github-workflows cursor-review.yml (single source of truth for panel, judge, prompts, scripts) instead of a standalone copy. Label-gated to the team; secret-bearing jobs skip fork PRs. Judge overridden to Opus 4.8. --- .github/workflows/pr-cursor-review.yaml | 58 +++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/pr-cursor-review.yaml diff --git a/.github/workflows/pr-cursor-review.yaml b/.github/workflows/pr-cursor-review.yaml new file mode 100644 index 00000000000..76d2c5b85bf --- /dev/null +++ b/.github/workflows/pr-cursor-review.yaml @@ -0,0 +1,58 @@ +# Description: Team-gated multi-model Cursor review — a thin caller for the +# reusable workflow in Comfy-Org/github-workflows, which is the single source of +# truth for the panel, judge, prompts, and scripts. Triggered by the +# 'cursor-review' label. +# +# Access control (team-only, two layers): +# 1. Only users with triage permission or higher can apply a label in a public +# repo, so the public cannot trigger this. +# 2. The reusable workflow's secret-bearing jobs do not run on fork PRs (forks +# get no secrets), so CURSOR_API_KEY is reachable only on internal branches. +name: 'PR: Cursor Review' + +on: + pull_request: + types: [labeled, unlabeled] + +permissions: + contents: read + pull-requests: write + +concurrency: + # Re-labeling cancels an in-flight run for the same PR + label. + group: cursor-review-pr-${{ github.event.pull_request.number }}-${{ github.event.label.name }} + cancel-in-progress: true + +jobs: + cursor-review: + # Pinned to github-workflows main HEAD: the reusable cursor-review workflow + # postdates the v1 tag, so there is no tagged release to pin to yet. + uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@41d1201821487c80b7752c5a692a53ca0396066b # main + with: + # Judge on Opus 4.8. The panel's Opus leg is fixed in the reusable matrix at + # the pinned SHA; once Comfy-Org/github-workflows#9 merges, bump the two SHAs + # here to pick up the 4.8 panel leg and drop this override (it will then + # match the new reusable default). + judge_model: claude-opus-4-8-thinking-xhigh + # Overriding replaces the reusable default wholesale, so this restates the + # generated/vendored defaults and adds this repo's heavy paths (Playwright + # snapshots, generated manager types). + diff_excludes: >- + :!**/package-lock.json + :!**/yarn.lock + :!**/pnpm-lock.yaml + :!**/node_modules/** + :!**/.claude/** + :!**/dist/** + :!**/vendor/** + :!**/*.generated.* + :!**/*.min.js + :!**/*.min.css + :!**/*-snapshots/** + :!src/workbench/extensions/manager/types/generatedManagerTypes.ts + # Load the prompts/scripts from the same ref as `uses:`. + workflows_ref: 41d1201821487c80b7752c5a692a53ca0396066b + secrets: + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} + # Optional — enables start/complete Slack DMs to the triggerer. + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} From 4cadbb8af975b53ea7c960815ce0aab2826722a6 Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Mon, 15 Jun 2026 15:42:26 -0700 Subject: [PATCH 2/9] fix: resolve review feedback --- .github/workflows/pr-cursor-review.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pr-cursor-review.yaml b/.github/workflows/pr-cursor-review.yaml index 76d2c5b85bf..8e9ad3b337a 100644 --- a/.github/workflows/pr-cursor-review.yaml +++ b/.github/workflows/pr-cursor-review.yaml @@ -25,6 +25,7 @@ concurrency: jobs: cursor-review: + if: github.event.action == 'labeled' && github.event.label.name == 'cursor-review' # Pinned to github-workflows main HEAD: the reusable cursor-review workflow # postdates the v1 tag, so there is no tagged release to pin to yet. uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@41d1201821487c80b7752c5a692a53ca0396066b # main From e498c4ae0d30183585568107fa789ec6f5572ddc Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Wed, 17 Jun 2026 11:17:58 -0700 Subject: [PATCH 3/9] ci: bump cursor-review SHA to github-workflows#9, drop judge_model override --- .github/workflows/pr-cursor-review.yaml | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/.github/workflows/pr-cursor-review.yaml b/.github/workflows/pr-cursor-review.yaml index 8e9ad3b337a..471b4a0f008 100644 --- a/.github/workflows/pr-cursor-review.yaml +++ b/.github/workflows/pr-cursor-review.yaml @@ -1,7 +1,7 @@ # Description: Team-gated multi-model Cursor review — a thin caller for the # reusable workflow in Comfy-Org/github-workflows, which is the single source of # truth for the panel, judge, prompts, and scripts. Triggered by the -# 'cursor-review' label. +#'cursor-review' label. # # Access control (team-only, two layers): # 1. Only users with triage permission or higher can apply a label in a public @@ -26,18 +26,14 @@ concurrency: jobs: cursor-review: if: github.event.action == 'labeled' && github.event.label.name == 'cursor-review' - # Pinned to github-workflows main HEAD: the reusable cursor-review workflow - # postdates the v1 tag, so there is no tagged release to pin to yet. - uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@41d1201821487c80b7752c5a692a53ca0396066b # main + # SHA-pinned per zizmor `unpinned-uses: hash-pin`. Bump this SHA to pick up + # upstream changes; keep `workflows_ref` matching so prompts/scripts load + # from the same commit as the workflow definition. + uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@047ca48febe3a6647608ed2e0c4331b491cb9d6a # github-workflows#9 with: - # Judge on Opus 4.8. The panel's Opus leg is fixed in the reusable matrix at - # the pinned SHA; once Comfy-Org/github-workflows#9 merges, bump the two SHAs - # here to pick up the 4.8 panel leg and drop this override (it will then - # match the new reusable default). - judge_model: claude-opus-4-8-thinking-xhigh - # Overriding replaces the reusable default wholesale, so this restates the - # generated/vendored defaults and adds this repo's heavy paths (Playwright - # snapshots, generated manager types). + # Overriding diff_excludes replaces the reusable default wholesale, so + # this restates the generated/vendored defaults and adds this repo's heavy + # paths (Playwright snapshots, generated manager types). diff_excludes: >- :!**/package-lock.json :!**/yarn.lock @@ -52,8 +48,9 @@ jobs: :!**/*-snapshots/** :!src/workbench/extensions/manager/types/generatedManagerTypes.ts # Load the prompts/scripts from the same ref as `uses:`. - workflows_ref: 41d1201821487c80b7752c5a692a53ca0396066b + workflows_ref: 047ca48febe3a6647608ed2e0c4331b491cb9d6a secrets: CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} # Optional — enables start/complete Slack DMs to the triggerer. SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + From 880582ab5d7d0c582c0cd12ea78f41705515ab6d Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 17 Jun 2026 18:21:48 +0000 Subject: [PATCH 4/9] [automated] Apply ESLint and Oxfmt fixes --- .github/workflows/pr-cursor-review.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/pr-cursor-review.yaml b/.github/workflows/pr-cursor-review.yaml index 471b4a0f008..6a6ff6b4d4e 100644 --- a/.github/workflows/pr-cursor-review.yaml +++ b/.github/workflows/pr-cursor-review.yaml @@ -53,4 +53,3 @@ jobs: CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} # Optional — enables start/complete Slack DMs to the triggerer. SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} - From 8d23daa33d52ab1345cf517a0f8a26964c16cde7 Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Wed, 17 Jun 2026 11:25:19 -0700 Subject: [PATCH 5/9] fix: resolve review feedback --- .github/workflows/pr-cursor-review.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-cursor-review.yaml b/.github/workflows/pr-cursor-review.yaml index 6a6ff6b4d4e..3a9f4a729b2 100644 --- a/.github/workflows/pr-cursor-review.yaml +++ b/.github/workflows/pr-cursor-review.yaml @@ -1,7 +1,7 @@ # Description: Team-gated multi-model Cursor review — a thin caller for the # reusable workflow in Comfy-Org/github-workflows, which is the single source of # truth for the panel, judge, prompts, and scripts. Triggered by the -#'cursor-review' label. +# 'cursor-review' label. # # Access control (team-only, two layers): # 1. Only users with triage permission or higher can apply a label in a public From 4136fcdf48f29b21454bc8a08cd08867877d9023 Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Wed, 10 Jun 2026 14:26:00 -0700 Subject: [PATCH 6/9] feat(assets): adopt cursor pagination in the flat-output infinite scroll - fetchFlatOutputs threads next_cursor into after on loadMore, falling back to offset paging for backends that mint no cursors - hasMore is now server-authoritative (has_more) instead of the page-length heuristic, with empty-page and non-advancing-cursor guards - reset path clears the cursor and stays a from-the-top head fetch --- src/stores/assetsStore.test.ts | 166 +++++++++++++++++++++++++++------ src/stores/assetsStore.ts | 25 +++-- 2 files changed, 153 insertions(+), 38 deletions(-) diff --git a/src/stores/assetsStore.test.ts b/src/stores/assetsStore.test.ts index dacaadfdc93..dab2e1cfc06 100644 --- a/src/stores/assetsStore.test.ts +++ b/src/stores/assetsStore.test.ts @@ -5,7 +5,10 @@ import { nextTick, watch } from 'vue' import { useAssetsStore } from '@/stores/assetsStore' import { api } from '@/scripts/api' -import type { AssetItem } from '@/platform/assets/schemas/assetSchema' +import type { + AssetItem, + AssetResponse +} from '@/platform/assets/schemas/assetSchema' import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes' import { assetService } from '@/platform/assets/services/assetService' @@ -25,6 +28,7 @@ vi.mock('@/scripts/api', () => ({ vi.mock('@/platform/assets/services/assetService', () => ({ assetService: { getAssetsByTag: vi.fn(), + getAssetsPageByTag: vi.fn(), getAllAssetsByTag: vi.fn(), getAssetsForNodeType: vi.fn(), invalidateInputAssetsIncludingPublic: vi.fn(), @@ -1517,49 +1521,143 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => { tags: ['output'] }) + const makePage = ( + assets: AssetItem[], + { + hasMore = false, + nextCursor + }: { hasMore?: boolean; nextCursor?: string } = {} + ): AssetResponse => ({ + assets, + total: assets.length, + has_more: hasMore, + ...(nextCursor === undefined ? {} : { next_cursor: nextCursor }) + }) + beforeEach(() => { setActivePinia(createTestingPinia({ stubActions: false })) vi.clearAllMocks() }) - it('fetches outputs via getAssetsByTag with the output tag and page size', async () => { - vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([ - makeAsset('a1', 'image1.png', 'hash1.png'), - makeAsset('a2', 'image2.png', 'hash2.png') - ]) + it('fetches the first page via getAssetsPageByTag with the output tag and page size', async () => { + vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce( + makePage([ + makeAsset('a1', 'image1.png', 'hash1.png'), + makeAsset('a2', 'image2.png', 'hash2.png') + ]) + ) const store = useAssetsStore() await store.updateFlatOutputs() - expect(assetService.getAssetsByTag).toHaveBeenCalledWith( + expect(assetService.getAssetsPageByTag).toHaveBeenCalledWith( 'output', true, - expect.objectContaining({ limit: FLAT_OUTPUT_PAGE_SIZE, offset: 0 }) + { + limit: FLAT_OUTPUT_PAGE_SIZE, + offset: 0 + } ) expect(store.flatOutputAssets.map((a) => a.id)).toEqual(['a1', 'a2']) }) - it('marks hasMore=false when the page is short', async () => { - vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([ - makeAsset('a1', 'one.png') - ]) + it('trusts server has_more over page size for a short page', async () => { + vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce( + makePage([makeAsset('a1', 'one.png')], { hasMore: true }) + ) const store = useAssetsStore() await store.updateFlatOutputs() - expect(store.flatOutputHasMore).toBe(false) + expect(store.flatOutputHasMore).toBe(true) }) - it('marks hasMore=true when a full page is returned', async () => { + it('marks hasMore=false when the server reports the last page', async () => { const fullPage = Array.from({ length: FLAT_OUTPUT_PAGE_SIZE }, (_, i) => makeAsset(`a${i}`, `f${i}.png`) ) - vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce(fullPage) + vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce( + makePage(fullPage, { hasMore: false }) + ) const store = useAssetsStore() await store.updateFlatOutputs() - expect(store.flatOutputHasMore).toBe(true) + expect(store.flatOutputHasMore).toBe(false) + }) + + it('threads the minted cursor into after on loadMore and omits offset', async () => { + vi.mocked(assetService.getAssetsPageByTag) + .mockResolvedValueOnce( + makePage([makeAsset('a1', 'f1.png')], { + hasMore: true, + nextCursor: 'cursor-1' + }) + ) + .mockResolvedValueOnce(makePage([makeAsset('a2', 'f2.png')])) + + const store = useAssetsStore() + await store.updateFlatOutputs() + await store.loadMoreFlatOutputs() + + expect(assetService.getAssetsPageByTag).toHaveBeenLastCalledWith( + 'output', + true, + { limit: FLAT_OUTPUT_PAGE_SIZE, after: 'cursor-1' } + ) + }) + + it('falls back to offset paging when the server mints no cursor', async () => { + vi.mocked(assetService.getAssetsPageByTag) + .mockResolvedValueOnce( + makePage([makeAsset('a1', 'f1.png'), makeAsset('a2', 'f2.png')], { + hasMore: true + }) + ) + .mockResolvedValueOnce(makePage([makeAsset('a3', 'f3.png')])) + + const store = useAssetsStore() + await store.updateFlatOutputs() + await store.loadMoreFlatOutputs() + + expect(assetService.getAssetsPageByTag).toHaveBeenLastCalledWith( + 'output', + true, + { limit: FLAT_OUTPUT_PAGE_SIZE, offset: 2 } + ) + }) + + it('stops when the server returns a non-advancing cursor', async () => { + vi.mocked(assetService.getAssetsPageByTag) + .mockResolvedValueOnce( + makePage([makeAsset('a1', 'f1.png')], { + hasMore: true, + nextCursor: 'stuck' + }) + ) + .mockResolvedValueOnce( + makePage([makeAsset('a2', 'f2.png')], { + hasMore: true, + nextCursor: 'stuck' + }) + ) + + const store = useAssetsStore() + await store.updateFlatOutputs() + await store.loadMoreFlatOutputs() + + expect(store.flatOutputHasMore).toBe(false) + }) + + it('treats an empty page as terminal even when has_more is true', async () => { + vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce( + makePage([], { hasMore: true }) + ) + + const store = useAssetsStore() + await store.updateFlatOutputs() + + expect(store.flatOutputHasMore).toBe(false) }) it('appends and dedupes on loadMoreFlatOutputs', async () => { @@ -1570,9 +1668,9 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => { makeAsset('a0', 'duplicate.png'), makeAsset('newId', 'new.png') ] - vi.mocked(assetService.getAssetsByTag) - .mockResolvedValueOnce(firstPage) - .mockResolvedValueOnce(secondPage) + vi.mocked(assetService.getAssetsPageByTag) + .mockResolvedValueOnce(makePage(firstPage, { hasMore: true })) + .mockResolvedValueOnce(makePage(secondPage)) const store = useAssetsStore() await store.updateFlatOutputs() @@ -1584,7 +1682,7 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => { it('records error and clears media on initial-fetch failure', async () => { const err = new Error('network down') - vi.mocked(assetService.getAssetsByTag).mockRejectedValueOnce(err) + vi.mocked(assetService.getAssetsPageByTag).mockRejectedValueOnce(err) const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) try { @@ -1599,37 +1697,43 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => { } }) - it('refresh resets pagination', async () => { - vi.mocked(assetService.getAssetsByTag) + it('refresh resets pagination and the cursor', async () => { + vi.mocked(assetService.getAssetsPageByTag) .mockResolvedValueOnce( - Array.from({ length: FLAT_OUTPUT_PAGE_SIZE }, (_, i) => - makeAsset(`a${i}`, `f${i}.png`) - ) + makePage([makeAsset('a1', 'f1.png')], { + hasMore: true, + nextCursor: 'cursor-1' + }) ) - .mockResolvedValueOnce([makeAsset('fresh', 'fresh.png')]) + .mockResolvedValueOnce(makePage([makeAsset('fresh', 'fresh.png')])) const store = useAssetsStore() await store.updateFlatOutputs() await store.updateFlatOutputs() + expect(assetService.getAssetsPageByTag).toHaveBeenLastCalledWith( + 'output', + true, + { limit: FLAT_OUTPUT_PAGE_SIZE, offset: 0 } + ) expect(store.flatOutputAssets.map((a) => a.id)).toEqual(['fresh']) expect(store.flatOutputHasMore).toBe(false) }) it('dedupes concurrent fetches into a single request', async () => { - let resolvePage!: (assets: AssetItem[]) => void - const pagePromise = new Promise((res) => { + let resolvePage!: (page: AssetResponse) => void + const pagePromise = new Promise((res) => { resolvePage = res }) - vi.mocked(assetService.getAssetsByTag).mockReturnValueOnce(pagePromise) + vi.mocked(assetService.getAssetsPageByTag).mockReturnValueOnce(pagePromise) const store = useAssetsStore() const p1 = store.updateFlatOutputs() const p2 = store.updateFlatOutputs() - expect(vi.mocked(assetService.getAssetsByTag)).toHaveBeenCalledTimes(1) + expect(vi.mocked(assetService.getAssetsPageByTag)).toHaveBeenCalledTimes(1) - resolvePage([makeAsset('shared-1', 'shared.png', 'h.png')]) + resolvePage(makePage([makeAsset('shared-1', 'shared.png', 'h.png')])) await Promise.all([p1, p2]) expect(store.flatOutputAssets.map((x) => x.id)).toEqual(['shared-1']) diff --git a/src/stores/assetsStore.ts b/src/stores/assetsStore.ts index 3889182465f..c17c0a2107e 100644 --- a/src/stores/assetsStore.ts +++ b/src/stores/assetsStore.ts @@ -267,6 +267,7 @@ export const useAssetsStore = defineStore('assets', () => { const flatOutputHasMore = ref(true) const flatOutputIsLoadingMore = ref(false) const flatOutputSeenIds = new Set() + let flatOutputNextCursor: string | undefined let flatOutputInFlight: Promise | null = null async function fetchFlatOutputs(loadMore: boolean): Promise { @@ -278,26 +279,36 @@ export const useAssetsStore = defineStore('assets', () => { } else { flatOutputLoading.value = true flatOutputOffset.value = 0 + flatOutputNextCursor = undefined flatOutputHasMore.value = true flatOutputSeenIds.clear() } flatOutputError.value = null flatOutputInFlight = (async () => { + const requestedAfter = loadMore ? flatOutputNextCursor : undefined try { - const page = await assetService.getAssetsByTag(OUTPUT_TAG, true, { + const page = await assetService.getAssetsPageByTag(OUTPUT_TAG, true, { limit: FLAT_OUTPUT_PAGE_SIZE, - offset: flatOutputOffset.value + ...(requestedAfter + ? { after: requestedAfter } + : { offset: flatOutputOffset.value }) }) + const batch = page.assets const fresh = loadMore - ? page.filter((asset) => !flatOutputSeenIds.has(asset.id)) - : page + ? batch.filter((asset) => !flatOutputSeenIds.has(asset.id)) + : batch for (const asset of fresh) flatOutputSeenIds.add(asset.id) flatOutputAssets.value = loadMore ? [...flatOutputAssets.value, ...fresh] - : page - flatOutputOffset.value += page.length - flatOutputHasMore.value = page.length === FLAT_OUTPUT_PAGE_SIZE + : batch + flatOutputOffset.value += batch.length + const nextCursor = page.next_cursor || undefined + const cursorStuck = + nextCursor !== undefined && nextCursor === requestedAfter + flatOutputNextCursor = cursorStuck ? undefined : nextCursor + flatOutputHasMore.value = + batch.length > 0 && page.has_more && !cursorStuck return flatOutputAssets.value } catch (err) { flatOutputError.value = err From 9e3b73805bcda1bc1e652131b362be5424d68a18 Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Wed, 10 Jun 2026 14:44:21 -0700 Subject: [PATCH 7/9] test(assets): harden flat-output cursor coverage from review findings - pin cursor preservation across a failed loadMore (retry resumes via after) - pin cursor clearing on refresh via the failed-refresh path (kills a surviving mutant on the reset-path clear) - rename error test to match actual preserve-on-error behavior - use vi.resetAllMocks to isolate per-test once-queues --- src/stores/assetsStore.test.ts | 68 ++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/src/stores/assetsStore.test.ts b/src/stores/assetsStore.test.ts index dab2e1cfc06..98806b3330b 100644 --- a/src/stores/assetsStore.test.ts +++ b/src/stores/assetsStore.test.ts @@ -1536,7 +1536,7 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => { beforeEach(() => { setActivePinia(createTestingPinia({ stubActions: false })) - vi.clearAllMocks() + vi.resetAllMocks() }) it('fetches the first page via getAssetsPageByTag with the output tag and page size', async () => { @@ -1680,7 +1680,7 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => { expect(store.flatOutputAssets.at(-1)?.id).toBe('newId') }) - it('records error and clears media on initial-fetch failure', async () => { + it('records error and resolves to an empty list on initial-fetch failure', async () => { const err = new Error('network down') vi.mocked(assetService.getAssetsPageByTag).mockRejectedValueOnce(err) const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -1697,7 +1697,69 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => { } }) - it('refresh resets pagination and the cursor', async () => { + it('preserves the cursor for retry when loadMore fails', async () => { + const err = new Error('network down') + vi.mocked(assetService.getAssetsPageByTag) + .mockResolvedValueOnce( + makePage([makeAsset('a1', 'f1.png')], { + hasMore: true, + nextCursor: 'cursor-1' + }) + ) + .mockRejectedValueOnce(err) + .mockResolvedValueOnce(makePage([makeAsset('a2', 'f2.png')])) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + try { + const store = useAssetsStore() + await store.updateFlatOutputs() + await store.loadMoreFlatOutputs() + + expect(store.flatOutputError).toBe(err) + expect(store.flatOutputAssets.map((a) => a.id)).toEqual(['a1']) + expect(store.flatOutputHasMore).toBe(true) + + await store.loadMoreFlatOutputs() + + expect(assetService.getAssetsPageByTag).toHaveBeenLastCalledWith( + 'output', + true, + { limit: FLAT_OUTPUT_PAGE_SIZE, after: 'cursor-1' } + ) + } finally { + consoleSpy.mockRestore() + } + }) + + it('restarts from the head when loadMore follows a failed refresh', async () => { + vi.mocked(assetService.getAssetsPageByTag) + .mockResolvedValueOnce( + makePage([makeAsset('a1', 'f1.png')], { + hasMore: true, + nextCursor: 'cursor-1' + }) + ) + .mockRejectedValueOnce(new Error('network down')) + .mockResolvedValueOnce(makePage([makeAsset('a2', 'f2.png')])) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + try { + const store = useAssetsStore() + await store.updateFlatOutputs() + await store.updateFlatOutputs() + await store.loadMoreFlatOutputs() + + expect(assetService.getAssetsPageByTag).toHaveBeenLastCalledWith( + 'output', + true, + { limit: FLAT_OUTPUT_PAGE_SIZE, offset: 0 } + ) + } finally { + consoleSpy.mockRestore() + } + }) + + it('refresh resets pagination', async () => { vi.mocked(assetService.getAssetsPageByTag) .mockResolvedValueOnce( makePage([makeAsset('a1', 'f1.png')], { From 00fc87f6b2bc90e0b200c9510e7ac217bbc3feec Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Wed, 10 Jun 2026 18:15:10 -0700 Subject: [PATCH 8/9] feat(assets): wire infinite scroll to the flat-output provider in the widget select dropdown - VirtualGrid approach-end forwards through FormDropdownMenu and FormDropdown to WidgetSelectDropdown, which loads the next page via the assets provider (cursor-paginated on cloud, jobs history on OSS) - guarded by hasMore/isLoadingMore and debounced, mirroring the assets sidebar idiom - loadingMore spinner row below the grid while a page is in flight --- .../components/WidgetSelectDropdown.vue | 12 ++++++++ .../components/form/dropdown/FormDropdown.vue | 8 +++++ .../form/dropdown/FormDropdownMenu.test.ts | 29 ++++++++++++++++++- .../form/dropdown/FormDropdownMenu.vue | 16 +++++++++- 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue index b4b4d6fb920..0589414ad4f 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue @@ -1,4 +1,5 @@ diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue index 8c7a5735c29..f3a785ccbc5 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue @@ -37,6 +37,7 @@ interface Props { ownershipOptions?: OwnershipFilterOption[] showBaseModelFilter?: boolean baseModelOptions?: FilterOption[] + loadingMore?: boolean isSelected?: ( selected: Set, item: FormDropdownItem, @@ -63,11 +64,16 @@ const { ownershipOptions, showBaseModelFilter, baseModelOptions, + loadingMore = false, isSelected = (selected, item, _index) => selected.has(item.id), searcher = defaultSearcher, items } = defineProps() +const emit = defineEmits<{ + (e: 'approach-end'): void +}>() + const placeholderText = computed( () => placeholder ?? t('widgets.uploadSelect.placeholder') ) @@ -314,9 +320,11 @@ function handleSearchEnter() { :candidate-label :is-selected="internalIsSelected" :max-selectable + :loading-more="loadingMore" @close="closeDropdown" @search-enter="handleSearchEnter" @item-click="handleSelection" + @approach-end="emit('approach-end')" /> diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.test.ts b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.test.ts index 5125065aa4b..049a2f7126b 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.test.ts @@ -1,3 +1,4 @@ +import userEvent from '@testing-library/user-event' import { render, screen } from '@testing-library/vue' import { describe, expect, it } from 'vitest' @@ -7,8 +8,9 @@ import type { FormDropdownItem, LayoutMode } from './types' const VirtualGridStub = { name: 'VirtualGrid', props: ['items', 'maxColumns', 'itemHeight', 'scrollerHeight'], + emits: ['approach-end'], template: - '
' + '
' } function createItem(id: string, name: string): FormDropdownItem { @@ -93,6 +95,31 @@ describe('FormDropdownMenu', () => { expect(virtualGrid.getAttribute('data-max-columns')).toBe('1') }) + it('forwards approach-end from the virtual grid', async () => { + const user = userEvent.setup() + const { emitted } = render(FormDropdownMenu, { + props: defaultProps, + global: globalConfig + }) + + await user.click(screen.getByTestId('virtual-grid')) + + expect(emitted()['approach-end']).toHaveLength(1) + }) + + it('shows the loading-more row only while loadingMore is set', async () => { + const { rerender } = render(FormDropdownMenu, { + props: { ...defaultProps, loadingMore: true }, + global: globalConfig + }) + + expect(screen.getByTestId('form-dropdown-loading-more')).toBeTruthy() + + await rerender({ ...defaultProps, loadingMore: false }) + + expect(screen.queryByTestId('form-dropdown-loading-more')).toBeNull() + }) + it('has data-capture-wheel="true" on the root element', () => { render(FormDropdownMenu, { props: defaultProps, diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.vue b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.vue index 77bbf01a6f0..a01cff9e531 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.vue @@ -27,6 +27,7 @@ interface Props { baseModelOptions?: FilterOption[] candidateIndex?: number candidateLabel?: string + loadingMore?: boolean } const { @@ -39,11 +40,13 @@ const { showBaseModelFilter, baseModelOptions, candidateIndex = -1, - candidateLabel + candidateLabel, + loadingMore = false } = defineProps() const emit = defineEmits<{ (e: 'item-click', item: FormDropdownItem, index: number): void (e: 'search-enter'): void + (e: 'approach-end'): void }>() const filterSelected = defineModel('filterSelected') @@ -158,6 +161,7 @@ const onWheel = (event: WheelEvent) => { :default-item-width="layoutConfig.itemWidth" :buffer-rows="2" class="mt-2 min-h-0 flex-1" + @approach-end="emit('approach-end')" > +
+ +
From 96c8b0e1ed6df7bf28716445039c39c0c96f6858 Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Thu, 18 Jun 2026 19:35:14 -0700 Subject: [PATCH 9/9] fix(assets): stop loadMore loop on all-seen pages; guard approach-end during initial load - flatOutputHasMore now gates on fresh.length > 0 instead of batch.length > 0, so cursor pages whose items are all duplicates terminate pagination rather than looping infinitely on approach-end - handleApproachEnd adds !loading guard to prevent concurrent loadMore during the opening refresh fetch --- .../vueNodes/widgets/components/WidgetSelectDropdown.vue | 1 + src/stores/assetsStore.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue index 1c2d983ea01..eecbe5edd18 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue @@ -155,6 +155,7 @@ function handleIsOpenUpdate(isOpen: boolean) { const handleApproachEnd = useDebounceFn(async () => { if ( outputMediaAssets.hasMore.value && + !outputMediaAssets.loading.value && !outputMediaAssets.isLoadingMore.value ) { await outputMediaAssets.loadMore() diff --git a/src/stores/assetsStore.ts b/src/stores/assetsStore.ts index c17c0a2107e..0a8b20e4b5e 100644 --- a/src/stores/assetsStore.ts +++ b/src/stores/assetsStore.ts @@ -308,7 +308,7 @@ export const useAssetsStore = defineStore('assets', () => { nextCursor !== undefined && nextCursor === requestedAfter flatOutputNextCursor = cursorStuck ? undefined : nextCursor flatOutputHasMore.value = - batch.length > 0 && page.has_more && !cursorStuck + fresh.length > 0 && page.has_more && !cursorStuck return flatOutputAssets.value } catch (err) { flatOutputError.value = err