diff --git a/e2e/queue-sidebar.spec.mjs b/e2e/queue-sidebar.spec.mjs
index 150f039..15bac2c 100644
--- a/e2e/queue-sidebar.spec.mjs
+++ b/e2e/queue-sidebar.spec.mjs
@@ -159,15 +159,124 @@ test.describe('Queue Sidebar E2E', () => {
await waitForQueueEmpty(page)
})
+ // ── #5 — Running card preview reflects live execution previews ────
+ test('running card preview shows img element during execution', async ({ page }) => {
+ const wf = await getModifiedWorkflow(page)
+ test.skip(!wf, 'No workflow available')
+
+ await queuePrompt(page, wf)
+
+ // Wait for a running card to appear
+ await expect(page.locator('[data-status="running"]').first())
+ .toBeAttached({ timeout: 10_000 })
+
+ // Poll until we observe a running card with an
element, OR the
+ // task finishes. Using a manual loop avoids a race where the card
+ // transitions to completed between the two separate evaluate() calls
+ // that toPass() would make.
+ let sawRunningWithImg = false
+ const pollStart = Date.now()
+ while (Date.now() - pollStart < 60_000) {
+ const result = await page.evaluate(() => {
+ const card = document.querySelector('[data-status="running"]')
+ if (!card) return 'completed'
+ return card.querySelector('.task-preview img') ? 'has-img' : 'no-img'
+ })
+ if (result === 'has-img') { sawRunningWithImg = true; break }
+ if (result === 'completed') break
+ await page.waitForTimeout(200)
+ }
+
+ expect(sawRunningWithImg).toBe(true)
+ await waitForQueueEmpty(page)
+ })
+
+ // ── #7 — Completed card shows preview; persists after reload ───────
+ test('completed card shows preview image and persists after page reload', async ({ page }) => {
+ const wf = await getModifiedWorkflow(page)
+ test.skip(!wf, 'No workflow available')
+
+ await queuePrompt(page, wf)
+ await waitForQueueEmpty(page)
+ await page.waitForTimeout(1500) // let render cycle complete
+
+ // Completed card must have an
whose src contains /view?filename=
+ const completedCard = page.locator('[data-status="completed"]').first()
+ await expect(completedCard).toBeAttached({ timeout: 10_000 })
+
+ // Wait for the img to be present before reading its src
+ const imgLocator = completedCard.locator('.task-preview img').first()
+ await expect(imgLocator).toBeAttached({ timeout: 5_000 })
+ const imgSrc = await imgLocator.getAttribute('src')
+ expect(imgSrc).toMatch(/\/view\?/)
+
+ // Extract filename to verify it's the same image after reload (cache hit)
+ const beforeFilename = new URLSearchParams(imgSrc.split('?')[1]).get('filename')
+ expect(beforeFilename).toBeTruthy()
+
+ // Reload and confirm the same card still shows the same image (localStorage cache hit)
+ await page.reload({ waitUntil: 'networkidle' })
+ await page.waitForTimeout(2000)
+ await openSidebar(page)
+
+ const reloadedImg = page.locator('[data-status="completed"] .task-preview img').first()
+ await expect(reloadedImg).toBeAttached({ timeout: 5_000 })
+ const reloadedSrc = await reloadedImg.getAttribute('src')
+ expect(reloadedSrc).toMatch(/\/view\?/)
+
+ // Same filename proves the preview came from localStorage cache, not a random re-fetch
+ const afterFilename = new URLSearchParams(reloadedSrc.split('?')[1]).get('filename')
+ expect(afterFilename).toBe(beforeFilename)
+ })
+
+ // ── #8 — localStorage cache is populated after execution ───────────
+ test('localStorage cache is populated with output after execution', async ({ page }) => {
+ const wf = await getModifiedWorkflow(page)
+ test.skip(!wf, 'No workflow available')
+
+ const queueResult = await queuePrompt(page, wf)
+ const promptId = queueResult?.prompt_id
+ test.skip(!promptId, 'Queue response did not include prompt_id')
+
+ await waitForQueueEmpty(page)
+ await page.waitForTimeout(1000)
+
+ const cacheEntry = await page.evaluate((pid) => {
+ try {
+ const raw = localStorage.getItem('queueSidebar.lastOutput')
+ if (!raw) return null
+ const cache = JSON.parse(raw)
+ return cache[pid] ?? null
+ } catch {
+ return null
+ }
+ }, promptId)
+
+ expect(cacheEntry).not.toBeNull()
+ expect(typeof cacheEntry.filename).toBe('string')
+ expect(cacheEntry.filename.length).toBeGreaterThan(0)
+ })
+
// ── #6 — No [QueueSidebar] console.warn during normal operation ────
test('no QueueSidebar console warnings during normal operation', async ({ page }) => {
- const warnings = []
- page.on('console', msg => {
- if (msg.type() === 'warning' && msg.text().includes('[QueueSidebar]')) {
- warnings.push(msg.text())
+ // Intercept console.warn at the JS level BEFORE page load so we capture
+ // warnings emitted during extension initialisation (i18n fetch failures,
+ // setup errors, etc.) — not just warnings emitted after test setup.
+ await page.addInitScript(() => {
+ window.__qsWarnings = []
+ const origWarn = console.warn.bind(console)
+ console.warn = (...args) => {
+ const msg = args.map(a => (typeof a === 'string' ? a : String(a))).join(' ')
+ if (msg.includes('[QueueSidebar]')) window.__qsWarnings.push(msg)
+ origWarn(...args)
}
})
+ // Navigate fresh so the init script runs before any extension code executes
+ await page.goto('/', { waitUntil: 'networkidle', timeout: 30_000 })
+ await page.waitForTimeout(2000) // let extensions load
+ await openSidebar(page)
+
const wf = await getModifiedWorkflow(page)
if (wf) {
await queuePrompt(page, wf)
@@ -175,6 +284,8 @@ test.describe('Queue Sidebar E2E', () => {
}
await page.waitForTimeout(1000)
+
+ const warnings = await page.evaluate(() => window.__qsWarnings)
expect(warnings).toEqual([])
})
})
diff --git a/tests/outputCache.test.js b/tests/outputCache.test.js
new file mode 100644
index 0000000..1ef0ca6
--- /dev/null
+++ b/tests/outputCache.test.js
@@ -0,0 +1,126 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import {
+ saveOutputCache,
+ loadOutputCache,
+ firstOutput,
+ OUTPUT_CACHE_KEY,
+ OUTPUT_CACHE_MAX,
+} from '../web/lib/outputCache.js'
+
+// clear localStorage before every test to ensure isolation across all describe blocks
+beforeEach(() => localStorage.clear())
+
+describe('loadOutputCache', () => {
+ it('returns null for unknown promptId', () => {
+ expect(loadOutputCache('nonexistent')).toBeNull()
+ })
+
+ it('returns null when localStorage value is corrupt JSON', () => {
+ localStorage.setItem(OUTPUT_CACHE_KEY, 'not-valid-json{{{')
+ expect(loadOutputCache('any')).toBeNull()
+ })
+
+ it('returns null for null or undefined promptId', () => {
+ expect(loadOutputCache(null)).toBeNull()
+ expect(loadOutputCache(undefined)).toBeNull()
+ })
+})
+
+describe('saveOutputCache', () => {
+ it('round-trips an output object', () => {
+ const output = { images: [{ filename: 'a.png', subfolder: '', type: 'output' }] }
+ saveOutputCache('prompt-1', output)
+ expect(loadOutputCache('prompt-1')).toEqual(output)
+ })
+
+ it('overwrites existing entry for the same promptId', () => {
+ const first = { images: [{ filename: 'a.png', subfolder: '', type: 'output' }] }
+ const second = { images: [{ filename: 'b.png', subfolder: '', type: 'output' }] }
+ saveOutputCache('prompt-1', first)
+ saveOutputCache('prompt-1', second)
+ expect(loadOutputCache('prompt-1')).toEqual(second)
+ })
+
+ it('returns null for a different promptId', () => {
+ saveOutputCache('prompt-1', { images: [{ filename: 'a.png' }] })
+ expect(loadOutputCache('prompt-2')).toBeNull()
+ })
+
+ it('does not write to cache for falsy promptId', () => {
+ saveOutputCache(null, { filename: 'img.png', subfolder: '', type: 'output' })
+ saveOutputCache(undefined, { filename: 'img.png', subfolder: '', type: 'output' })
+ // cache 應維持空白
+ const raw = localStorage.getItem(OUTPUT_CACHE_KEY)
+ expect(raw).toBeNull()
+ })
+
+ it('evicts the oldest entry when exceeding OUTPUT_CACHE_MAX', () => {
+ for (let i = 0; i < OUTPUT_CACHE_MAX; i++) {
+ saveOutputCache(`p-${i}`, { filename: `img-${i}.png`, subfolder: '', type: 'temp' })
+ }
+ saveOutputCache('p-overflow', { filename: 'overflow.png', subfolder: '', type: 'temp' })
+
+ expect(loadOutputCache('p-0')).toBeNull() // 被踢出
+ expect(loadOutputCache('p-1')).not.toBeNull() // 仍保留
+ expect(loadOutputCache(`p-${OUTPUT_CACHE_MAX - 1}`)).not.toBeNull() // 仍保留
+ expect(loadOutputCache('p-overflow')).not.toBeNull() // 新增的存在
+ })
+})
+
+describe('firstOutput', () => {
+ it('returns null when outputs is empty', () => {
+ expect(firstOutput({}, 'prompt-1')).toBeNull()
+ })
+
+ it('returns the first image found in dict order (no cache)', () => {
+ const outputs = {
+ nodeA: { images: [{ filename: 'a.png', subfolder: '', type: 'output' }] },
+ nodeB: { images: [{ filename: 'b.png', subfolder: '', type: 'output' }] },
+ }
+ expect(firstOutput(outputs, 'no-cache')).toEqual({ filename: 'a.png', subfolder: '', type: 'output' })
+ })
+
+ it('returns cached output when promptId matches, ignoring dict order', () => {
+ const cached = { filename: 'cached.png', subfolder: 'sub', type: 'output' }
+ saveOutputCache('prompt-cached', cached)
+ const outputs = {
+ nodeA: { images: [{ filename: 'a.png', subfolder: '', type: 'output' }] },
+ }
+ expect(firstOutput(outputs, 'prompt-cached')).toEqual(cached)
+ })
+
+ it('falls back to dict iteration when cache misses', () => {
+ const outputs = {
+ nodeA: { images: [{ filename: 'fallback.png', subfolder: '', type: 'output' }] },
+ }
+ expect(firstOutput(outputs, 'cache-miss')).toEqual({ filename: 'fallback.png', subfolder: '', type: 'output' })
+ })
+
+ it('returns null when outputs contains only empty arrays', () => {
+ const outputs = {
+ nodeA: { images: [] },
+ nodeB: { gifs: [] },
+ }
+ expect(firstOutput(outputs, 'prompt-empty')).toBeNull()
+ })
+
+ it.each([
+ ['gifs', { gifs: [{ filename: 'anim.gif', subfolder: '', type: 'output' }] }, { filename: 'anim.gif', subfolder: '', type: 'output' }],
+ ['video', { video: { filename: 'clip.mp4', subfolder: '', type: 'output' } }, { filename: 'clip.mp4', subfolder: '', type: 'output' }],
+ ['audio', { audio: { filename: 'sound.wav', subfolder: '', type: 'output' } }, { filename: 'sound.wav', subfolder: '', type: 'output' }],
+ ])('returns item for %s key', (_key, nodeOut, expected) => {
+ expect(firstOutput({ n: nodeOut }, undefined)).toEqual(expected)
+ })
+
+ it('prefers images key over later keys in the same node', () => {
+ const imgItem = { filename: 'img.png', subfolder: '', type: 'output' }
+ const gifItem = { filename: 'anim.gif', subfolder: '', type: 'output' }
+ const outputs = {
+ nodeA: {
+ images: [imgItem],
+ gifs: [gifItem],
+ },
+ }
+ expect(firstOutput(outputs, 'prompt-prefer')).toEqual(imgItem)
+ })
+})
diff --git a/tests/preview.test.js b/tests/preview.test.js
new file mode 100644
index 0000000..b8ea364
--- /dev/null
+++ b/tests/preview.test.js
@@ -0,0 +1,287 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import {
+ renderImagePreview,
+ renderVideoPreview,
+ renderOutputPreview,
+ makePreview,
+ updateRunningPreview,
+} from '../web/lib/preview.js'
+
+function makeWrap() {
+ return document.createElement('div')
+}
+
+function makeCardWithPreview() {
+ const card = document.createElement('div')
+ const preview = document.createElement('div')
+ preview.className = 'task-preview'
+ card.appendChild(preview)
+ return card
+}
+
+// ─── renderImagePreview ───────────────────────────────────────────────────────
+
+describe('renderImagePreview', () => {
+ it('contain mode: appends two img elements (blurred bg + foreground)', () => {
+ const wrap = makeWrap()
+ renderImagePreview(wrap, 'http://example.com/img.png', 'contain')
+ const imgs = wrap.querySelectorAll('img')
+ expect(imgs).toHaveLength(2)
+ expect(imgs[0].src).toBe('http://example.com/img.png')
+ expect(imgs[1].src).toBe('http://example.com/img.png')
+ })
+
+ it('cover mode: appends a single img element', () => {
+ const wrap = makeWrap()
+ renderImagePreview(wrap, 'http://example.com/img.png', 'cover')
+ const imgs = wrap.querySelectorAll('img')
+ expect(imgs).toHaveLength(1)
+ expect(imgs[0].src).toBe('http://example.com/img.png')
+ })
+
+ it('both modes set loading="lazy"', () => {
+ const wrapContain = makeWrap()
+ renderImagePreview(wrapContain, 'http://x.com/a.png', 'contain')
+ for (const img of wrapContain.querySelectorAll('img')) {
+ expect(img.loading).toBe('lazy')
+ }
+
+ const wrapCover = makeWrap()
+ renderImagePreview(wrapCover, 'http://x.com/b.png', 'cover')
+ expect(wrapCover.querySelector('img').loading).toBe('lazy')
+ })
+})
+
+// ─── renderVideoPreview ───────────────────────────────────────────────────────
+
+describe('renderVideoPreview', () => {
+ it('appends a video element with correct src, muted, and loop attributes', () => {
+ const wrap = makeWrap()
+ renderVideoPreview(wrap, 'http://example.com/clip.mp4', 'contain')
+ const vid = wrap.querySelector('video')
+ expect(vid).not.toBeNull()
+ expect(vid.src).toBe('http://example.com/clip.mp4')
+ expect(vid.muted).toBe(true)
+ expect(vid.loop).toBe(true)
+ })
+})
+
+// ─── renderOutputPreview ─────────────────────────────────────────────────────
+
+describe('renderOutputPreview', () => {
+ let firstOutput
+ let viewUrl
+
+ beforeEach(() => {
+ viewUrl = vi.fn((output) => `http://comfy/view?filename=${output.filename}`)
+ firstOutput = vi.fn()
+ })
+
+ it('passes task.outputs and task.promptId to firstOutput', () => {
+ firstOutput.mockReturnValue(null)
+ const outputs = { '1': { images: [{ filename: 'a.png' }] } }
+ const task = { promptId: 'p-abc', status: 'completed', outputs }
+ renderOutputPreview(makeWrap(), task, { firstOutput, viewUrl, imageFit: 'contain' })
+ expect(firstOutput).toHaveBeenCalledWith(outputs, 'p-abc')
+ })
+
+ it('shows check-circle icon when firstOutput returns null (completed)', () => {
+ firstOutput.mockReturnValue(null)
+ const wrap = makeWrap()
+ renderOutputPreview(wrap, { promptId: 'p1', status: 'completed', outputs: {} }, { firstOutput, viewUrl, imageFit: 'contain' })
+ expect(wrap.querySelector('.pi-check-circle')).not.toBeNull()
+ })
+
+ it('shows exclamation-circle icon when firstOutput returns null (failed)', () => {
+ firstOutput.mockReturnValue(null)
+ const wrap = makeWrap()
+ renderOutputPreview(wrap, { promptId: 'p1', status: 'failed', outputs: {} }, { firstOutput, viewUrl, imageFit: 'contain' })
+ expect(wrap.querySelector('.pi-exclamation-circle')).not.toBeNull()
+ })
+
+ it('renders img with the URL returned by viewUrl for image output', () => {
+ firstOutput.mockReturnValue({ filename: 'result.png', subfolder: '', type: 'output' })
+ const wrap = makeWrap()
+ renderOutputPreview(wrap, { promptId: 'p1', status: 'completed', outputs: {} }, { firstOutput, viewUrl, imageFit: 'contain' })
+ // contain mode renders two imgs (blurred bg + foreground); both get the view URL
+ const imgs = wrap.querySelectorAll('img')
+ expect(imgs.length).toBeGreaterThan(0)
+ expect(imgs[0].src).toBe('http://comfy/view?filename=result.png')
+ })
+
+ it('renders video with correct src, muted, and loop for video output', () => {
+ firstOutput.mockReturnValue({ filename: 'clip.mp4', subfolder: '', type: 'output' })
+ const wrap = makeWrap()
+ renderOutputPreview(wrap, { promptId: 'p1', status: 'completed', outputs: {} }, { firstOutput, viewUrl, imageFit: 'contain' })
+ const vid = wrap.querySelector('video')
+ expect(vid).not.toBeNull()
+ expect(vid.src).toBe('http://comfy/view?filename=clip.mp4')
+ expect(vid.muted).toBe(true)
+ expect(vid.loop).toBe(true)
+ })
+
+ it('renders volume-up icon for audio output', () => {
+ firstOutput.mockReturnValue({ filename: 'sound.mp3', subfolder: '', type: 'output' })
+ const wrap = makeWrap()
+ renderOutputPreview(wrap, { promptId: 'p1', status: 'completed', outputs: {} }, { firstOutput, viewUrl, imageFit: 'contain' })
+ expect(wrap.querySelector('.pi-volume-up')).not.toBeNull()
+ })
+
+ it('renders file icon for unknown file type', () => {
+ firstOutput.mockReturnValue({ filename: 'data.json', subfolder: '', type: 'output' })
+ const wrap = makeWrap()
+ renderOutputPreview(wrap, { promptId: 'p1', status: 'completed', outputs: {} }, { firstOutput, viewUrl, imageFit: 'contain' })
+ expect(wrap.querySelector('.pi-file')).not.toBeNull()
+ })
+
+ it('calls viewUrl with the output returned by firstOutput', () => {
+ const output = { filename: 'upscaled.png', subfolder: 'sub', type: 'output' }
+ firstOutput.mockReturnValue(output)
+ const wrap = makeWrap()
+ renderOutputPreview(wrap, { promptId: 'p1', status: 'completed', outputs: {} }, { firstOutput, viewUrl, imageFit: 'contain' })
+ expect(viewUrl).toHaveBeenCalledWith(output)
+ })
+})
+
+// ─── updateRunningPreview ─────────────────────────────────────────────────────
+
+describe('updateRunningPreview', () => {
+ it('creates img and sets src when progressUrl is provided', () => {
+ const card = makeCardWithPreview()
+ updateRunningPreview(card, 'blob:http://example.com/abc')
+ const img = card.querySelector('.task-preview img')
+ expect(img).not.toBeNull()
+ expect(img.src).toBe('blob:http://example.com/abc')
+ })
+
+ it('reuses existing img element and updates src', () => {
+ const card = makeCardWithPreview()
+ const preview = card.querySelector('.task-preview')
+ const img = document.createElement('img')
+ img.src = 'blob:http://example.com/old'
+ preview.appendChild(img)
+
+ updateRunningPreview(card, 'blob:http://example.com/new')
+
+ const imgs = card.querySelectorAll('.task-preview img')
+ expect(imgs).toHaveLength(1)
+ expect(imgs[0].src).toBe('blob:http://example.com/new')
+ })
+
+ it('calling with the same progressUrl does not create a duplicate img element', () => {
+ const card = makeCardWithPreview()
+ const preview = card.querySelector('.task-preview')
+ const img = document.createElement('img')
+ img.src = 'blob:http://example.com/same'
+ preview.appendChild(img)
+
+ updateRunningPreview(card, 'blob:http://example.com/same')
+
+ // Only one img should exist — not a second one appended
+ expect(card.querySelectorAll('.task-preview img')).toHaveLength(1)
+ })
+
+ it('shows spinner when progressUrl is null', () => {
+ const card = makeCardWithPreview()
+ updateRunningPreview(card, null)
+ expect(card.querySelector('.task-preview .pi-spin')).not.toBeNull()
+ })
+
+ it('replaces img with spinner when progressUrl becomes null', () => {
+ const card = makeCardWithPreview()
+ updateRunningPreview(card, 'blob:http://example.com/abc')
+ updateRunningPreview(card, null)
+ expect(card.querySelector('.task-preview .pi-spin')).not.toBeNull()
+ expect(card.querySelector('.task-preview img')).toBeNull()
+ })
+
+ it('does not recreate spinner if one is already present', () => {
+ const card = makeCardWithPreview()
+ const preview = card.querySelector('.task-preview')
+ preview.innerHTML = ''
+ const spinnerBefore = preview.querySelector('.pi-spin')
+
+ updateRunningPreview(card, null)
+
+ expect(preview.querySelector('.pi-spin')).toBe(spinnerBefore) // same node, no re-render
+ })
+
+ it('does nothing when card has no .task-preview element', () => {
+ const card = document.createElement('div')
+ expect(() => updateRunningPreview(card, 'blob:http://example.com/abc')).not.toThrow()
+ })
+})
+
+// ─── makePreview ──────────────────────────────────────────────────────────────
+
+describe('makePreview', () => {
+ let deps
+
+ beforeEach(() => {
+ deps = {
+ progressUrl: null,
+ firstOutput: vi.fn().mockReturnValue(null),
+ viewUrl: vi.fn((o) => `http://comfy/view?filename=${o.filename}`),
+ imageFit: 'contain',
+ }
+ })
+
+ it('always sets className to "task-preview"', () => {
+ const wrap = makePreview({ status: 'pending', outputs: {} }, deps)
+ expect(wrap.className).toBe('task-preview')
+ })
+
+ it('pending: shows dots placeholder', () => {
+ const wrap = makePreview({ status: 'pending', outputs: {} }, deps)
+ expect(wrap.textContent).toContain('···')
+ })
+
+ it('running with progressUrl: shows img with that src', () => {
+ deps.progressUrl = 'blob:http://example.com/latent'
+ const wrap = makePreview({ status: 'running', outputs: {} }, deps)
+ const img = wrap.querySelector('img')
+ expect(img).not.toBeNull()
+ expect(img.src).toBe('blob:http://example.com/latent')
+ })
+
+ it('running without progressUrl: shows spinner', () => {
+ deps.progressUrl = null
+ const wrap = makePreview({ status: 'running', outputs: {} }, deps)
+ expect(wrap.querySelector('.pi-spin')).not.toBeNull()
+ expect(wrap.querySelector('img')).toBeNull()
+ })
+
+ it('completed with no output: shows check-circle icon', () => {
+ const wrap = makePreview({ status: 'completed', outputs: {}, promptId: 'p1' }, deps)
+ expect(wrap.querySelector('.pi-check-circle')).not.toBeNull()
+ })
+
+ it('completed with image output: shows img with the URL from viewUrl', () => {
+ deps.firstOutput.mockReturnValue({ filename: 'result.png', subfolder: '', type: 'output' })
+ const wrap = makePreview({ status: 'completed', outputs: {}, promptId: 'p1' }, deps)
+ const imgs = wrap.querySelectorAll('img')
+ expect(imgs.length).toBeGreaterThan(0)
+ expect(imgs[0].src).toBe('http://comfy/view?filename=result.png')
+ })
+
+ it('failed with no output: shows exclamation-circle icon', () => {
+ const wrap = makePreview({ status: 'failed', outputs: {}, promptId: 'p1' }, deps)
+ expect(wrap.querySelector('.pi-exclamation-circle')).not.toBeNull()
+ })
+
+ it('cancelled with no output: shows check-circle icon (non-failed fallback)', () => {
+ const wrap = makePreview({ status: 'cancelled', outputs: {}, promptId: 'p1' }, deps)
+ expect(wrap.querySelector('.pi-check-circle')).not.toBeNull()
+ })
+
+ it('running status: does not call firstOutput (progressUrl path only)', () => {
+ deps.progressUrl = 'blob:http://example.com/latent'
+ makePreview({ status: 'running', outputs: {}, promptId: 'p1' }, deps)
+ expect(deps.firstOutput).not.toHaveBeenCalled()
+ })
+
+ it('pending status: does not call firstOutput', () => {
+ makePreview({ status: 'pending', outputs: {} }, deps)
+ expect(deps.firstOutput).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/lib/outputCache.js b/web/lib/outputCache.js
new file mode 100644
index 0000000..1427292
--- /dev/null
+++ b/web/lib/outputCache.js
@@ -0,0 +1,78 @@
+// ─── Output Cache ─────────────────────────────────────────────────────────────
+
+export const OUTPUT_CACHE_KEY = 'queueSidebar.lastOutput'
+export const OUTPUT_CACHE_MAX = 200
+
+/**
+ * Persist an output object for a given promptId into localStorage.
+ * Evicts the oldest entry when the cache exceeds OUTPUT_CACHE_MAX.
+ * All JSON errors are silently caught.
+ *
+ * @param {string} promptId
+ * @param {object} output
+ */
+export function saveOutputCache(promptId, output) {
+ if (!promptId) return
+ try {
+ const raw = localStorage.getItem(OUTPUT_CACHE_KEY)
+ let cache = {}
+ try {
+ if (raw) cache = JSON.parse(raw)
+ } catch {
+ cache = {}
+ }
+ cache[promptId] = output
+ while (Object.keys(cache).length > OUTPUT_CACHE_MAX) {
+ delete cache[Object.keys(cache)[0]]
+ }
+ localStorage.setItem(OUTPUT_CACHE_KEY, JSON.stringify(cache))
+ } catch {
+ // silent — storage failure must not crash the page
+ }
+}
+
+/**
+ * Load the cached output for a given promptId.
+ * Returns null if not found or if JSON parsing fails.
+ *
+ * @param {string} promptId
+ * @returns {object|null}
+ */
+export function loadOutputCache(promptId) {
+ if (!promptId) return null
+ try {
+ const raw = localStorage.getItem(OUTPUT_CACHE_KEY)
+ if (!raw) return null
+ const cache = JSON.parse(raw)
+ return cache[promptId] ?? null
+ } catch {
+ return null
+ }
+}
+
+// Keys checked in priority order when scanning node outputs
+const OUTPUT_KEYS = ['images', 'gifs', 'video', 'audio']
+
+/**
+ * Find the first media item from task outputs.
+ * Checks the localStorage cache first; falls back to iterating output values.
+ *
+ * @param {object} outputs - map of nodeId → node output object
+ * @param {string} promptId
+ * @returns {object|null} - a single media item with a `filename` property, or null
+ */
+export function firstOutput(outputs = {}, promptId) {
+ const cached = loadOutputCache(promptId)
+ if (cached !== null) return cached
+
+ for (const nodeOutput of Object.values(outputs)) {
+ for (const key of OUTPUT_KEYS) {
+ if (!(key in nodeOutput)) continue
+ const val = nodeOutput[key]
+ const item = Array.isArray(val) ? val[0] : val
+ if (item && item.filename) return item
+ }
+ }
+
+ return null
+}
diff --git a/web/lib/preview.js b/web/lib/preview.js
index 48bf46b..47789dd 100644
--- a/web/lib/preview.js
+++ b/web/lib/preview.js
@@ -47,7 +47,7 @@ export function renderVideoPreview(wrap, url, imageFit) {
*/
export function renderOutputPreview(wrap, task, deps) {
const { firstOutput, viewUrl, imageFit } = deps
- const output = firstOutput(task.outputs)
+ const output = firstOutput(task.outputs, task.promptId)
if (!output) {
const icon = task.status === 'failed' ? 'pi-exclamation-circle' : 'pi-check-circle'
const color = task.status === 'failed' ? STATUS_COLOR.failed : 'var(--p-text-muted-color,#888)'
diff --git a/web/queue-sidebar.js b/web/queue-sidebar.js
index 1137ca9..cfb9b6f 100644
--- a/web/queue-sidebar.js
+++ b/web/queue-sidebar.js
@@ -11,6 +11,7 @@ import {
getComfyLocale, hookQueuePrompt, reorderQueueTab,
updateTabBadge, normalizeQueue, normalizeHistoryItem,
} from './lib/comfyAdapter.js'
+import { firstOutput, saveOutputCache } from './lib/outputCache.js' // saveOutputCache wired in onExecuted (see below)
// ─── i18n ──────────────────────────────────────────────────────────────────────
// Translations are loaded from web/locales/.json at startup.
@@ -53,6 +54,11 @@ const state = {
// ─── Data helpers ─────────────────────────────────────────────────────────────
+function clearProgressUrl() {
+ if (state.progressUrl?.startsWith('blob:')) URL.revokeObjectURL(state.progressUrl)
+ state.progressUrl = null
+}
+
function viewUrl(result) {
const p = new URLSearchParams({
filename: result.filename,
@@ -62,18 +68,6 @@ function viewUrl(result) {
return api.apiURL(`/view?${p}`)
}
-function firstOutput(outputs = {}) {
- for (const nodeOutputs of Object.values(outputs)) {
- for (const key of ['images', 'gifs', 'video', 'audio']) {
- const val = nodeOutputs[key]
- if (!val) continue
- const item = Array.isArray(val) ? val[0] : val
- if (item?.filename) return item
- }
- }
- return null
-}
-
// ─── Data fetching ────────────────────────────────────────────────────────────
async function fetchQueue() {
@@ -114,7 +108,7 @@ async function refresh() {
function galleryItems() {
return state.history
.map((task) => {
- const output = firstOutput(task.outputs)
+ const output = firstOutput(task.outputs, task.promptId)
if (!output) return null
const type = mediaType(output.filename)
if (type !== 'image' && type !== 'video') return null
@@ -177,7 +171,7 @@ function makeCard(task) {
card.appendChild(overlay)
card.addEventListener('click', () => {
- const output = firstOutput(task.outputs)
+ const output = firstOutput(task.outputs, task.promptId)
if (!output) return
const type = mediaType(output.filename)
if (type !== 'image' && type !== 'video') return
@@ -281,10 +275,7 @@ function onStatus() {
}
function onExecutionStart({ detail }) {
- if (state.progressUrl) {
- URL.revokeObjectURL(state.progressUrl)
- state.progressUrl = null
- }
+ clearProgressUrl()
// Immediately move the task from pending → running using the prompt_id from the WS
// event, without waiting for a /queue API fetch. This ensures the badge and card
// appear instantly — including for fast or fully-cached workflows where the task
@@ -304,12 +295,29 @@ function onExecutionStart({ detail }) {
function onProgressPreview({ detail }) {
if (state.running.length === 0) return
- const prev = state.progressUrl
- if (prev) URL.revokeObjectURL(prev)
+ clearProgressUrl()
state.progressUrl = URL.createObjectURL(detail)
render()
}
+function onExecuted({ detail }) {
+ const prompt_id = detail?.prompt_id
+ const output = detail?.output
+ if (!prompt_id || !state.running.some(t => t.promptId === prompt_id)) return
+ for (const key of ['images', 'gifs', 'video', 'audio']) {
+ const val = output?.[key]
+ if (!val || (Array.isArray(val) && val.length === 0)) continue
+ const item = Array.isArray(val) ? val[0] : val
+ if (item?.filename) {
+ clearProgressUrl()
+ state.progressUrl = viewUrl(item)
+ saveOutputCache(prompt_id, item)
+ render()
+ break
+ }
+ }
+}
+
// ─── Badge style injection ─────────────────────────────────────────────────────
function injectBadgeStyle() {
@@ -356,6 +364,7 @@ app.registerExtension({
api.addEventListener('status', onStatus)
api.addEventListener('execution_start', onExecutionStart)
api.addEventListener('b_preview', onProgressPreview)
+ api.addEventListener('executed', onExecuted)
app.extensionManager.registerSidebarTab({
id: 'queue',
@@ -370,10 +379,7 @@ app.registerExtension({
},
destroy() {
- if (state.progressUrl) {
- URL.revokeObjectURL(state.progressUrl)
- state.progressUrl = null
- }
+ clearProgressUrl()
gridEl = null
scrollEl = null
},