Skip to content

Commit c2f7d1c

Browse files
authored
feat(ui): Add media history to studio pages (e.g. past images) (#9151)
Signed-off-by: Richard Palethorpe <io@richiejp.com>
1 parent afe7956 commit c2f7d1c

15 files changed

Lines changed: 705 additions & 83 deletions

File tree

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
import { test, expect } from '@playwright/test'
2+
3+
function mockCapabilities(page, capabilities) {
4+
return page.route('**/api/models/capabilities', (route) => {
5+
route.fulfill({
6+
contentType: 'application/json',
7+
body: JSON.stringify({ data: capabilities }),
8+
})
9+
})
10+
}
11+
12+
function mockImageGeneration(page, images) {
13+
return page.route('**/v1/images/generations', (route) => {
14+
route.fulfill({
15+
contentType: 'application/json',
16+
body: JSON.stringify({
17+
created: Math.floor(Date.now() / 1000),
18+
data: images,
19+
}),
20+
})
21+
})
22+
}
23+
24+
function mockVideoGeneration(page, videos) {
25+
return page.route('**/video', (route) => {
26+
if (route.request().method() !== 'POST') return route.continue()
27+
route.fulfill({
28+
contentType: 'application/json',
29+
body: JSON.stringify({
30+
created: Math.floor(Date.now() / 1000),
31+
data: videos,
32+
}),
33+
})
34+
})
35+
}
36+
37+
function mockTTSGeneration(page, filename) {
38+
return page.route('**/tts', (route) => {
39+
if (route.request().method() !== 'POST') return route.continue()
40+
const wavHeader = new Uint8Array(44) // minimal WAV header
41+
route.fulfill({
42+
status: 200,
43+
headers: {
44+
'Content-Type': 'audio/wav',
45+
'Content-Disposition': `attachment; filename="${filename}"`,
46+
},
47+
body: Buffer.from(wavHeader),
48+
})
49+
})
50+
}
51+
52+
function mockSoundGeneration(page, filename) {
53+
return page.route('**/v1/sound-generation', (route) => {
54+
if (route.request().method() !== 'POST') return route.continue()
55+
const wavHeader = new Uint8Array(44)
56+
route.fulfill({
57+
status: 200,
58+
headers: {
59+
'Content-Type': 'audio/wav',
60+
'Content-Disposition': `attachment; filename="${filename}"`,
61+
},
62+
body: Buffer.from(wavHeader),
63+
})
64+
})
65+
}
66+
67+
test.describe('Media History - Image Generation', () => {
68+
test.beforeEach(async ({ page }) => {
69+
await mockCapabilities(page, [{ id: 'test-image-model', capabilities: ['FLAG_IMAGE'] }])
70+
})
71+
72+
test('history entry appears after image generation', async ({ page }) => {
73+
await mockImageGeneration(page, [{ url: '/generated-images/test.png' }])
74+
75+
await page.goto('/app/image')
76+
await expect(page.getByRole('button', { name: 'test-image-model' })).toBeVisible({ timeout: 10_000 })
77+
78+
await page.locator('.textarea').first().fill('a beautiful sunset')
79+
await page.locator('button[type="submit"]').click()
80+
81+
// Image should appear in preview
82+
await expect(page.locator('.media-result-grid img')).toBeVisible({ timeout: 10_000 })
83+
84+
// History entry should appear
85+
await expect(page.getByTestId('media-history-item')).toHaveCount(1)
86+
await expect(page.getByTestId('media-history-item')).toContainText('a beautiful sunset')
87+
})
88+
89+
test('clicking history entry loads image in preview', async ({ page }) => {
90+
let callCount = 0
91+
await page.route('**/v1/images/generations', (route) => {
92+
callCount++
93+
route.fulfill({
94+
contentType: 'application/json',
95+
body: JSON.stringify({
96+
created: Math.floor(Date.now() / 1000),
97+
data: [{ url: `/generated-images/img${callCount}.png` }],
98+
}),
99+
})
100+
})
101+
102+
await page.goto('/app/image')
103+
await expect(page.getByRole('button', { name: 'test-image-model' })).toBeVisible({ timeout: 10_000 })
104+
105+
// Generate first image
106+
await page.locator('.textarea').first().fill('first prompt')
107+
await page.locator('button[type="submit"]').click()
108+
await expect(page.locator('.media-result-grid img')).toBeVisible({ timeout: 10_000 })
109+
110+
// Generate second image
111+
await page.locator('.textarea').first().fill('second prompt')
112+
await page.locator('button[type="submit"]').click()
113+
await expect(page.locator('.media-result-grid img[src="/generated-images/img2.png"]')).toBeVisible({ timeout: 10_000 })
114+
115+
// Click first history entry (second in list since newest first)
116+
const items = page.getByTestId('media-history-item')
117+
await expect(items).toHaveCount(2)
118+
await items.last().click()
119+
120+
// Preview should show the first image
121+
await expect(page.locator('.media-result-grid img[src="/generated-images/img1.png"]')).toBeVisible()
122+
})
123+
124+
test('history persists across navigation', async ({ page }) => {
125+
await mockImageGeneration(page, [{ url: '/generated-images/persist.png' }])
126+
127+
await page.goto('/app/image')
128+
await expect(page.getByRole('button', { name: 'test-image-model' })).toBeVisible({ timeout: 10_000 })
129+
130+
await page.locator('.textarea').first().fill('persist test')
131+
await page.locator('button[type="submit"]').click()
132+
await expect(page.getByTestId('media-history-item')).toHaveCount(1, { timeout: 10_000 })
133+
134+
// Wait for debounced save
135+
await page.waitForTimeout(600)
136+
137+
// Navigate away and back
138+
await page.goto('/app')
139+
await page.goto('/app/image')
140+
141+
// Re-register the mock for capabilities after navigation
142+
await expect(page.getByTestId('media-history')).toBeVisible({ timeout: 10_000 })
143+
await expect(page.getByTestId('media-history-item')).toHaveCount(1)
144+
await expect(page.getByTestId('media-history-item')).toContainText('persist test')
145+
})
146+
147+
test('delete removes a history entry', async ({ page }) => {
148+
await mockImageGeneration(page, [{ url: '/generated-images/del.png' }])
149+
150+
await page.goto('/app/image')
151+
await expect(page.getByRole('button', { name: 'test-image-model' })).toBeVisible({ timeout: 10_000 })
152+
153+
await page.locator('.textarea').first().fill('delete me')
154+
await page.locator('button[type="submit"]').click()
155+
await expect(page.getByTestId('media-history-item')).toHaveCount(1, { timeout: 10_000 })
156+
157+
// Hover and click delete
158+
await page.getByTestId('media-history-item').hover()
159+
await page.getByTestId('media-history-delete').click()
160+
161+
await expect(page.getByTestId('media-history-item')).toHaveCount(0)
162+
})
163+
164+
test('clear all removes all entries', async ({ page }) => {
165+
let callCount = 0
166+
await page.route('**/v1/images/generations', (route) => {
167+
callCount++
168+
route.fulfill({
169+
contentType: 'application/json',
170+
body: JSON.stringify({
171+
created: Math.floor(Date.now() / 1000),
172+
data: [{ url: `/generated-images/img${callCount}.png` }],
173+
}),
174+
})
175+
})
176+
177+
await page.goto('/app/image')
178+
await expect(page.getByRole('button', { name: 'test-image-model' })).toBeVisible({ timeout: 10_000 })
179+
180+
// Generate two images
181+
await page.locator('.textarea').first().fill('first')
182+
await page.locator('button[type="submit"]').click()
183+
await expect(page.getByTestId('media-history-item')).toHaveCount(1, { timeout: 10_000 })
184+
185+
await page.locator('.textarea').first().fill('second')
186+
await page.locator('button[type="submit"]').click()
187+
await expect(page.getByTestId('media-history-item')).toHaveCount(2, { timeout: 10_000 })
188+
189+
// Click clear all
190+
await page.locator('.media-history-clear-btn').click()
191+
await expect(page.getByTestId('media-history-item')).toHaveCount(0)
192+
})
193+
})
194+
195+
test.describe('Media History - TTS', () => {
196+
test.beforeEach(async ({ page }) => {
197+
await mockCapabilities(page, [{ id: 'test-tts-model', capabilities: ['FLAG_TTS'] }])
198+
})
199+
200+
test('TTS history entry appears with server URL from Content-Disposition', async ({ page }) => {
201+
await mockTTSGeneration(page, 'tts.wav')
202+
203+
await page.goto('/app/tts')
204+
await expect(page.getByRole('button', { name: 'test-tts-model' })).toBeVisible({ timeout: 10_000 })
205+
206+
await page.locator('.textarea').fill('hello world')
207+
await page.locator('button[type="submit"]').click()
208+
209+
// History entry should appear
210+
await expect(page.getByTestId('media-history-item')).toHaveCount(1, { timeout: 10_000 })
211+
await expect(page.getByTestId('media-history-item')).toContainText('hello world')
212+
213+
// Click the history entry to load it
214+
await page.getByTestId('media-history-item').click()
215+
216+
// Audio element should use server URL
217+
await expect(page.getByTestId('history-audio')).toHaveAttribute('src', '/generated-audio/tts.wav')
218+
})
219+
})
220+
221+
test.describe('Media History - Sound Generation', () => {
222+
test.beforeEach(async ({ page }) => {
223+
await mockCapabilities(page, [{ id: 'test-sound-model', capabilities: ['FLAG_SOUND_GENERATION'] }])
224+
})
225+
226+
test('Sound generation history entry appears', async ({ page }) => {
227+
await mockSoundGeneration(page, 'sound.wav')
228+
229+
await page.goto('/app/sound')
230+
await expect(page.getByRole('button', { name: 'test-sound-model' })).toBeVisible({ timeout: 10_000 })
231+
232+
await page.locator('.textarea').first().fill('upbeat jazz')
233+
await page.locator('button[type="submit"]').click()
234+
235+
await expect(page.getByTestId('media-history-item')).toHaveCount(1, { timeout: 10_000 })
236+
await expect(page.getByTestId('media-history-item')).toContainText('upbeat jazz')
237+
})
238+
})
239+
240+
test.describe('Media History - Video Generation', () => {
241+
test.beforeEach(async ({ page }) => {
242+
await mockCapabilities(page, [{ id: 'test-video-model', capabilities: ['FLAG_VIDEO'] }])
243+
})
244+
245+
test('Video history entry appears after generation', async ({ page }) => {
246+
await mockVideoGeneration(page, [{ url: '/generated-videos/test.mp4' }])
247+
248+
await page.goto('/app/video')
249+
await expect(page.getByRole('button', { name: 'test-video-model' })).toBeVisible({ timeout: 10_000 })
250+
251+
await page.locator('.textarea').first().fill('a running cat')
252+
await page.locator('button[type="submit"]').click()
253+
254+
await expect(page.getByTestId('media-history-item')).toHaveCount(1, { timeout: 10_000 })
255+
await expect(page.getByTestId('media-history-item')).toContainText('a running cat')
256+
})
257+
})

core/http/react-ui/src/App.css

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2765,6 +2765,140 @@
27652765
width: 100%;
27662766
}
27672767

2768+
/* Media generation history */
2769+
.media-history {
2770+
margin-top: var(--spacing-md);
2771+
}
2772+
2773+
.media-history-clear-btn {
2774+
background: none;
2775+
border: none;
2776+
color: var(--color-text-muted);
2777+
cursor: pointer;
2778+
padding: 2px 6px;
2779+
font-size: 0.75rem;
2780+
border-radius: var(--radius-sm);
2781+
transition: color var(--duration-fast);
2782+
}
2783+
2784+
.media-history-clear-btn:hover {
2785+
color: var(--color-danger);
2786+
}
2787+
2788+
.media-history-list {
2789+
max-height: 400px;
2790+
overflow-y: auto;
2791+
padding: var(--spacing-xs) 0;
2792+
}
2793+
2794+
.media-history-empty {
2795+
text-align: center;
2796+
color: var(--color-text-muted);
2797+
font-size: 0.8125rem;
2798+
padding: var(--spacing-md);
2799+
}
2800+
2801+
.media-history-item {
2802+
display: flex;
2803+
align-items: flex-start;
2804+
gap: var(--spacing-xs);
2805+
padding: var(--spacing-sm);
2806+
border-radius: var(--radius-md);
2807+
cursor: pointer;
2808+
font-size: 0.8125rem;
2809+
color: var(--color-text-secondary);
2810+
transition: background var(--duration-fast), transform var(--duration-fast);
2811+
margin-bottom: 2px;
2812+
}
2813+
2814+
.media-history-item:hover {
2815+
background: var(--color-primary-light);
2816+
transform: translateX(2px);
2817+
}
2818+
2819+
.media-history-item.active {
2820+
background: var(--color-primary-light);
2821+
color: var(--color-primary);
2822+
}
2823+
2824+
.media-history-item-thumb {
2825+
width: 32px;
2826+
height: 32px;
2827+
flex-shrink: 0;
2828+
border-radius: var(--radius-sm);
2829+
overflow: hidden;
2830+
display: flex;
2831+
align-items: center;
2832+
justify-content: center;
2833+
background: var(--color-bg-tertiary);
2834+
color: var(--color-text-muted);
2835+
font-size: 0.75rem;
2836+
}
2837+
2838+
.media-history-item-thumb img {
2839+
width: 100%;
2840+
height: 100%;
2841+
object-fit: cover;
2842+
}
2843+
2844+
.media-history-item-info {
2845+
flex: 1;
2846+
min-width: 0;
2847+
display: flex;
2848+
flex-direction: column;
2849+
gap: 2px;
2850+
}
2851+
2852+
.media-history-item-top {
2853+
display: flex;
2854+
align-items: center;
2855+
gap: var(--spacing-xs);
2856+
}
2857+
2858+
.media-history-item-prompt {
2859+
flex: 1;
2860+
overflow: hidden;
2861+
text-overflow: ellipsis;
2862+
white-space: nowrap;
2863+
font-weight: 500;
2864+
font-size: 0.8125rem;
2865+
}
2866+
2867+
.media-history-item-time {
2868+
font-size: 0.625rem;
2869+
color: var(--color-text-muted);
2870+
white-space: nowrap;
2871+
flex-shrink: 0;
2872+
}
2873+
2874+
.media-history-item-model {
2875+
font-size: 0.6875rem;
2876+
color: var(--color-text-muted);
2877+
overflow: hidden;
2878+
text-overflow: ellipsis;
2879+
white-space: nowrap;
2880+
line-height: 1.3;
2881+
}
2882+
2883+
.media-history-item-delete {
2884+
opacity: 0;
2885+
background: none;
2886+
border: none;
2887+
color: var(--color-text-muted);
2888+
cursor: pointer;
2889+
padding: 2px;
2890+
font-size: 0.75rem;
2891+
transition: opacity var(--duration-fast);
2892+
}
2893+
2894+
.media-history-item:hover .media-history-item-delete {
2895+
opacity: 1;
2896+
}
2897+
2898+
.media-history-item-delete:hover {
2899+
color: var(--color-danger);
2900+
}
2901+
27682902
/* Responsive */
27692903
@media (max-width: 1023px) {
27702904
.main-content,

0 commit comments

Comments
 (0)