diff --git a/docs/api-reference/openapi.json b/docs/api-reference/openapi.json index e1cad06..6821ed4 100644 --- a/docs/api-reference/openapi.json +++ b/docs/api-reference/openapi.json @@ -8490,6 +8490,11 @@ }, "updatedAt": { "type": "integer" + }, + "thumbnailUrl": { + "type": "string", + "format": "uri", + "description": "Saved thumbnail reference for an inspected URL, when available." } } }, diff --git a/platform/src/durable-objects/user-account.ts b/platform/src/durable-objects/user-account.ts index 30fa20f..af01f56 100644 --- a/platform/src/durable-objects/user-account.ts +++ b/platform/src/durable-objects/user-account.ts @@ -119,7 +119,8 @@ export class UserAccountDO extends DurableObject { const existing = this.ctx.storage.sql.exec<{ id: string }>('SELECT id FROM recent_sources WHERE source_key = ?', key).toArray()[0]; const entry: RecentSource = { id: existing?.id ?? crypto.randomUUID(), input: input.input, title: input.title, - kind: input.snapshot.kind, updatedAt: this.nextSourceUpdate() }; + kind: input.snapshot.kind, updatedAt: this.nextSourceUpdate(), + ...(input.snapshot.kind === 'inspection' && input.snapshot.inspector.thumbnailUrl ? { thumbnailUrl: input.snapshot.inspector.thumbnailUrl } : {}) }; this.ctx.storage.transactionSync(() => { this.ctx.storage.sql.exec(`INSERT INTO recent_sources (id, source_key, input, title, kind, updated_at, snapshot) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(source_key) DO UPDATE SET @@ -133,9 +134,21 @@ export class UserAccountDO extends DurableObject { listSources(): RecentSource[] { this.assertActive(); - return this.ctx.storage.sql.exec<{ id: string; input: string; title: string; kind: RecentSource['kind']; updated_at: number }>( - 'SELECT id, input, title, kind, updated_at FROM recent_sources ORDER BY updated_at DESC, rowid DESC LIMIT ?', RECENT_SOURCE_LIMIT, - ).toArray().map(({ updated_at, ...row }) => ({ ...row, updatedAt: updated_at })); + return this.ctx.storage.sql.exec<{ id: string; input: string; title: string; kind: RecentSource['kind']; updated_at: number; thumbnail_url: string | null }>( + `SELECT id, input, title, kind, updated_at, json_extract(snapshot, '$.inspector.thumbnailUrl') AS thumbnail_url + FROM recent_sources ORDER BY updated_at DESC, rowid DESC LIMIT ?`, RECENT_SOURCE_LIMIT, + ).toArray().map(({ updated_at, thumbnail_url, ...row }) => ({ ...row, updatedAt: updated_at, ...(thumbnail_url ? { thumbnailUrl: thumbnail_url } : {}) })); + } + + listSourceReferences() { + return this.listSources().map(source => ({ source, snapshot: source.kind === 'inspection' && !source.thumbnailUrl + ? sourceReferenceSchema.parse(JSON.parse(this.ctx.storage.sql.exec<{ snapshot: string }>('SELECT snapshot FROM recent_sources WHERE id = ?', source.id).one().snapshot)) : null })); + } + + cacheSourceThumbnail(id: string, url: string): void { + this.assertActive(); + this.ctx.storage.sql.exec(`UPDATE recent_sources SET snapshot = json_set(snapshot, '$.inspector.thumbnailUrl', ?) + WHERE id = ? AND kind = 'inspection' AND json_extract(snapshot, '$.inspector.thumbnailUrl') IS NULL`, z.string().url().parse(url), z.string().uuid().parse(id)); } getSource(id: string): { source: RecentSource; snapshot: SourceReference } | null { diff --git a/platform/src/lib/source-history-storage.ts b/platform/src/lib/source-history-storage.ts index 9f67102..41c6549 100644 --- a/platform/src/lib/source-history-storage.ts +++ b/platform/src/lib/source-history-storage.ts @@ -5,6 +5,26 @@ import { readYouTubeCacheEntry } from './youtube-cache-coordinator'; import { routeInput, withYouTubeMetadata } from './youtube'; import { sourceSnapshotSchema, type SaveSourceInput, type SaveReferencedSource, type SourceReference, type SourceSnapshot } from './source-history'; +function thumbnailUrl(data: Record): string | undefined { + const images = data.thumbnails as Array<{ url?: string; width?: number }> | undefined; + return images?.filter(image => typeof image.url === 'string').sort((a, b) => (b.width ?? 0) - (a.width ?? 0))[0]?.url; +} + +/** Older entries resolve their thumbnail from their existing saved metadata version. */ +export async function sourceThumbnail(env: Env, reference: SourceReference): Promise { + if (reference.kind !== 'inspection') return; + if (reference.inspector.thumbnailUrl) return reference.inspector.thumbnailUrl; + const metadata = reference.inspector.assets.metadata; + if (metadata) { + const stored = await videoCatalog(env)?.readVersion>(metadata); + return stored ? thumbnailUrl(stored.value) : undefined; + } + if (reference.inspector.entity) { + const object = await env.VIDEO_ASSETS.get(reference.inspector.entity); + if (object) return thumbnailUrl(await object.json>()); + } +} + // Only public provider payloads enter this bucket. Inputs and user selections stay in the user DO. async function saveShared(env: Env, value: unknown): Promise { const payload = JSON.stringify(value); @@ -57,6 +77,7 @@ export async function referenceSource(env: Env, value: SaveSourceInput): Promise requestedData: source.requestedData, dataErrors: source.dataErrors, assets, entity: source.type !== 'video' ? await saveShared(env, data) : undefined, channel: channel ? await saveShared(env, channel) : undefined, + thumbnailUrl: thumbnailUrl(data), }, } }; } diff --git a/platform/src/lib/source-history.ts b/platform/src/lib/source-history.ts index 9a65701..e3b040c 100644 --- a/platform/src/lib/source-history.ts +++ b/platform/src/lib/source-history.ts @@ -50,13 +50,14 @@ export const sourceReferenceSchema = z.discriminatedUnion('kind', [ requestedData: z.array(dataset), dataErrors: inspector.shape.dataErrors, assets: z.partialRecord(z.enum(['metadata', 'transcript', 'comments']), assetReference), entity: sharedReference.optional(), channel: sharedReference.optional(), + thumbnailUrl: z.string().url().optional(), }) }), ]); export const saveReferencedSourceSchema = z.object({ input: z.string().trim().min(1).max(500), title: z.string().max(300), snapshot: sourceReferenceSchema }); export type SourceReference = z.infer; export type SaveReferencedSource = z.infer; export interface RecentSource { - id: string; input: string; title: string; kind: SourceSnapshot['kind']; updatedAt: number; + id: string; input: string; title: string; kind: SourceSnapshot['kind']; updatedAt: number; thumbnailUrl?: string; } export function sourceIdentity(input: SaveReferencedSource): string { diff --git a/platform/src/openapi.ts b/platform/src/openapi.ts index b6c3da2..e5200e0 100644 --- a/platform/src/openapi.ts +++ b/platform/src/openapi.ts @@ -2180,6 +2180,7 @@ export const openApiDocument = { RecentSource: { type: 'object', required: ['id', 'input', 'title', 'kind', 'updatedAt'], properties: { id: { type: 'string', format: 'uuid' }, input: { type: 'string' }, title: { type: 'string' }, kind: { type: 'string', enum: ['search', 'inspection'] }, updatedAt: { type: 'integer' }, + thumbnailUrl: { type: 'string', format: 'uri', description: 'Saved thumbnail reference for an inspected URL, when available.' }, } }, CompactAgentRun: z.toJSONSchema(compactAgentRunSchema, { target: 'openapi-3.0' }), AgentRunReceipt: { diff --git a/platform/src/routes/session/session.index.ts b/platform/src/routes/session/session.index.ts index c446299..9b6b1bd 100644 --- a/platform/src/routes/session/session.index.ts +++ b/platform/src/routes/session/session.index.ts @@ -1,7 +1,7 @@ import { framePreviewPrefix } from '../../agents/runtime/frame-previews'; import { userAccountInstanceName } from '../../agents/runtime/identity'; import { MAX_SOURCE_SNAPSHOT_BYTES, saveSourceSchema, sourceIdSchema } from '../../lib/source-history'; -import { referenceSource, restoreSource } from '../../lib/source-history-storage'; +import { referenceSource, restoreSource, sourceThumbnail } from '../../lib/source-history-storage'; import { deleteAgentAccountData } from '../../agents/runtime/account-deletion'; import { Hono } from 'hono'; import type { App, ImportPayload } from '../../types'; @@ -57,7 +57,16 @@ for (const path of SESSION_ONLY_ROUTE_PATTERNS) sessionRoutes.use(path, requireS sessionRoutes.get('/sources/recent', async (c) => { const account = c.env.USER_ACCOUNT.getByName(await userAccountInstanceName(requireUser(c).id)); - return c.json({ sources: await account.listSources() }); + const entries = await account.listSourceReferences(); + const sources = await Promise.all(entries.map(async ({ source, snapshot }) => { + if (!snapshot) return source; + // Missing old metadata must not prevent the rest of the history from loading. + const thumbnailUrl = await sourceThumbnail(c.env, snapshot).catch(() => undefined); + if (!thumbnailUrl) return source; + await account.cacheSourceThumbnail(source.id, thumbnailUrl); + return { ...source, thumbnailUrl }; + })); + return c.json({ sources }); }); sessionRoutes.post('/sources/recent', async (c) => { diff --git a/platform/test/e2e/agent-sessions.spec.ts b/platform/test/e2e/agent-sessions.spec.ts index 99b0012..2617e2f 100644 --- a/platform/test/e2e/agent-sessions.spec.ts +++ b/platform/test/e2e/agent-sessions.spec.ts @@ -98,6 +98,7 @@ test('allowed account can search, paginate, open history and read cited answers' await page.getByRole('textbox', { name: 'Search your sessions' }).fill('does not exist'); await page.getByRole('button', { name: 'Search', exact: true }).click(); await expect(page.getByText('No matching sessions')).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Search your sessions' })).toBeVisible(); await page.getByRole('textbox', { name: 'Search your sessions' }).fill('Fable'); await page.getByRole('button', { name: 'Search', exact: true }).click(); await expect(page.getByRole('heading', { name: 'Fable and Astra: key takeaways' })).toBeVisible(); diff --git a/platform/test/e2e/dashboard-api.spec.ts b/platform/test/e2e/dashboard-api.spec.ts index a18304e..b9483b4 100644 --- a/platform/test/e2e/dashboard-api.spec.ts +++ b/platform/test/e2e/dashboard-api.spec.ts @@ -79,6 +79,10 @@ test('recent searches restore their saved result list and dataset choices', asyn await expect(page.getByRole('textbox', { name: 'Video search or YouTube URL' })).toHaveValue(source.input); await expect(page.getByRole('checkbox', { name: 'Comments' })).toBeChecked(); expect(providerReads).toBe(1); + await page.getByRole('link', { name: 'Sources', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Recent sources', exact: true })).toBeVisible(); + await expect(page.getByText('Saved comparison result')).toHaveCount(0); + await expect(page.getByRole('textbox', { name: 'Video search or YouTube URL' })).toHaveValue(''); }); test('recent source load failures expose a retry and preserve the form', async ({ page }) => { @@ -94,6 +98,93 @@ test('recent source load failures expose a retry and preserve the form', async ( await expect(page.getByRole('textbox', { name: 'Video search or YouTube URL' })).toBeVisible(); }); +for (const theme of ['light', 'dark'] as const) test(`Sources and Agent share their empty history presentation (${theme})`, async ({ page }, testInfo) => { + await page.emulateMedia({ colorScheme: theme }); + await page.setViewportSize({ width: 1280, height: 800 }); + const scenario = await accountScenario(page, { responses: { + '/v1/sources/recent': { body: { sources: [] } }, + '/v1/agent/sessions': { body: { sessions: [], nextCursor: null } }, + } }); + const presentation = async (title: string) => page.getByRole('heading', { name: title, exact: true }).evaluate(`heading => { + const style = getComputedStyle(heading); + const container = getComputedStyle(heading.parentElement); + return { fontSize: style.fontSize, fontWeight: style.fontWeight, lineHeight: style.lineHeight, padding: container.padding }; + }`); + try { + await page.goto('/dashboard/sources'); + await expect(page.getByRole('heading', { name: 'No recent sources yet' })).toBeVisible(); + await expect(page.getByText('Search for a topic or paste a YouTube link above. Your recent sources will appear here.')).toBeVisible(); + const sourceStyle = await presentation('No recent sources yet'); + await page.screenshot({ path: testInfo.outputPath(`sources-empty-${theme}.png`), fullPage: true }); + await page.getByRole('link', { name: 'Agent', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'No sessions yet' })).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Search your sessions' })).toHaveCount(0); + expect(await presentation('No sessions yet')).toEqual(sourceStyle); + await page.screenshot({ path: testInfo.outputPath(`sessions-empty-${theme}.png`), fullPage: true }); + await page.setViewportSize({ width: 390, height: 844 }); + await expect(page.getByRole('heading', { name: 'No sessions yet' })).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Search your sessions' })).toHaveCount(0); + await page.goto('/dashboard/sources'); + await expect(page.getByRole('heading', { name: 'No recent sources yet' })).toBeVisible(); + expect(await page.evaluate('document.documentElement.scrollWidth <= window.innerWidth')).toBe(true); + } finally { await scenario.clear(); } +}); + +test('recent sources use matching skeleton rows while history is loading', async ({ page }, testInfo) => { + const scenario = await accountScenario(page, { delays: ['/v1/sources/recent'] }); + try { + await page.goto('/dashboard/sources'); + const skeleton = page.getByRole('status', { name: 'Loading recent sources', exact: true }); + await expect(skeleton).toBeVisible(); + await expect(skeleton.locator('.recent-source-skeleton')).toHaveCount(3); + await expect(skeleton.locator('.ui-bar').first()).toBeVisible(); + await expect(page.locator('p').filter({ hasText: 'Loading recent sources' })).toHaveCount(0); + await expect(page.getByText('No recent sources yet')).toHaveCount(0); + await page.screenshot({ path: testInfo.outputPath('recent-sources-loading.png'), fullPage: true }); + await scenario.release(); + await expect(skeleton).toHaveCount(0); + } finally { await scenario.release(); await scenario.clear(); } +}); + +for (const theme of ['light', 'dark'] as const) test(`recent URL thumbnails and search icons share dimensions on mobile (${theme})`, async ({ page }, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.emulateMedia({ colorScheme: theme }); + const sources = [ + { id: '25c715cb-30f4-4d24-a66f-1cab99d4b4c6', input: `https://youtube.com/watch?v=${videoId}`, title: 'Saved video thumbnail', kind: 'inspection', updatedAt: Date.now(), thumbnailUrl: 'https://thumb.example.test/video.svg' }, + { id: '90abdb7b-af0c-429c-9c9d-02949a76d1c6', input: 'Opus vs Astra', title: 'Saved search query', kind: 'search', updatedAt: Date.now() }, + ]; + await page.route('**/api/platform/v1/sources/recent', route => route.fulfill({ json: { sources } })); + await page.route('https://thumb.example.test/video.svg', route => route.fulfill({ contentType: 'image/svg+xml', body: '' })); + await page.goto('/dashboard/sources'); + const video = page.getByRole('button', { name: /Saved video thumbnail/ }); + const search = page.getByRole('button', { name: /Saved search query/ }); + await expect(video.locator('img')).toBeVisible(); + await expect(search.locator('svg')).toBeVisible(); + const videoBounds = await video.locator('.recent-source-visual').boundingBox(); + const searchBounds = await search.locator('.recent-source-visual').boundingBox(); + expect(videoBounds?.width).toBe(searchBounds?.width); + expect(videoBounds?.height).toBe(searchBounds?.height); + expect(videoBounds?.width).toBe(64); + expect(await page.evaluate('document.documentElement.scrollWidth <= window.innerWidth')).toBe(true); + await page.screenshot({ path: testInfo.outputPath(`recent-sources-${theme}.png`), fullPage: true }); +}); + +test('clicking the active Sources sidebar clears an inspector and a pending request', async ({ page }) => { + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + await page.route(`**/videos/${videoId}/transcript`, async route => { await gate; await route.fulfill({ json: transcript }).catch(() => {}); }); + await page.goto('/dashboard/sources'); + await page.getByRole('textbox', { name: 'Video search or YouTube URL' }).fill(`https://youtube.com/watch?v=${videoId}`); + await page.getByRole('button', { name: /Inspect/ }).click(); + await expect(page.getByRole('heading', { name: 'Transcript deadline regression' })).toBeVisible(); + await page.getByRole('link', { name: 'Sources', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Recent sources', exact: true })).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Video search or YouTube URL' })).toHaveValue(''); + release(); + await expect(page.getByText(transcript.text, { exact: true })).toHaveCount(0); + await expect(page.getByRole('heading', { name: 'Transcript deadline regression' })).toHaveCount(0); +}); + test('slow transcript finishes after the old browser deadline', async ({ page }) => { let release!: () => void; const gate = new Promise(resolve => { release = resolve; }); @@ -345,13 +436,14 @@ test('settings renders account data on the server and requests only its own reso } finally { await scenario.clear(); } }); -test('research drafts survive a visit to the settings route', async ({ page }) => { +test('the Sources sidebar opens the default view after visiting another route', async ({ page }) => { await page.goto('/dashboard?section=discover'); await page.getByRole('textbox', { name: 'Video search or YouTube URL' }).fill('a draft research query'); await page.getByRole('link', { name: 'Settings', exact: true }).click(); await expect(page).toHaveURL(/\/dashboard\/settings$/); await page.getByRole('link', { name: 'Sources', exact: true }).click(); - await expect(page.getByRole('textbox', { name: 'Video search or YouTube URL' })).toHaveValue('a draft research query'); + await expect(page.getByRole('textbox', { name: 'Video search or YouTube URL' })).toHaveValue(''); + await expect(page.getByRole('heading', { name: 'Recent sources', exact: true })).toBeVisible(); }); test('legacy settings links preserve checkout and email confirmation parameters', async ({ page }) => { @@ -422,7 +514,7 @@ test('settings renders while navigation access checks are pending', async ({page }finally{await scenario.clear();} }); -test('an active transcript finishes while settings is open and is reused on return',async({page})=>{ +test('an active transcript finishes in the background and the Sources sidebar returns home',async({page})=>{ let release!:()=>void;const gate=new Promise(resolve=>{release=resolve;});let reads=0; await page.route(`**/videos/${videoId}/transcript`,async route=>{reads++;await gate;await route.fulfill({json:transcript});}); await page.goto('/dashboard/sources'); @@ -431,9 +523,13 @@ test('an active transcript finishes while settings is open and is reused on retu await expect.poll(()=>reads).toBe(1); await page.getByRole('link',{name:'Settings',exact:true}).click(); await expect(page.getByRole('heading',{name:'Workspace settings'})).toBeVisible(); + const remembered=page.waitForResponse(response=>response.url().endsWith('/v1/sources/recent')&&response.request().method()==='POST'); release(); + await remembered; await page.getByRole('link',{name:'Sources',exact:true}).click(); - await expect(page.getByText('Transcript arrived successfully.',{exact:true})).toBeVisible(); + await expect(page.getByRole('heading',{name:'Recent sources',exact:true})).toBeVisible(); + await expect(page.getByText('Transcript arrived successfully.',{exact:true})).toHaveCount(0); + await expect(page.getByRole('textbox',{name:'Video search or YouTube URL'})).toHaveValue(''); expect(reads).toBe(1); }); diff --git a/platform/test/user-account-do.integration.test.ts b/platform/test/user-account-do.integration.test.ts index 93cbfaf..6828ada 100644 --- a/platform/test/user-account-do.integration.test.ts +++ b/platform/test/user-account-do.integration.test.ts @@ -8,6 +8,7 @@ import { jsonError } from '../src/lib/http'; import { Hono } from 'hono'; import { sessionRoutes } from '../src/routes/session/session.index'; import type { App, AuthPrincipal } from '../src/types'; +import { userAccountInstanceName } from '../src/agents/runtime/identity'; const env = workerEnv as Env; function sourceApp(userId: string, method: AuthPrincipal['method'] = 'session') { @@ -52,7 +53,8 @@ describe('UserAccountDO', () => { }); test('recent videos store catalog references, reuse shared assets and restore the original version', async () => { const id = 'abcdefghijk'; - const metadata = { id, title: 'Shared video', thumbnails: [] }; + const thumbnailUrl = 'https://i.ytimg.com/vi/abcdefghijk/hqdefault.jpg'; + const metadata = { id, title: 'Shared video', thumbnails: [{ url: thumbnailUrl, width: 480, height: 360 }] }; const transcript = { videoId: id, text: 'Original transcript', segments: [{ text: 'Original transcript', startMs: 0, endMs: 1000, durationMs: 1000 }], track: { name: 'English', kind: 'asr', languageCode: 'en' }, meta: { source: 'youtube', fetchedAt: new Date().toISOString(), partial: false, warnings: [] } }; await saveVideoResource(env, { kind: 'video', id }, metadata, Date.now(), 60_000); @@ -62,6 +64,17 @@ describe('UserAccountDO', () => { } } }); const first = env.USER_ACCOUNT.getByName('source-first'), second = env.USER_ACCOUNT.getByName('source-second'); const saved = await first.saveSource(references); + expect(saved.thumbnailUrl).toBe(thumbnailUrl); + expect((await first.listSources())[0]?.thumbnailUrl).toBe(thumbnailUrl); + const legacy = structuredClone(references); + if (legacy.snapshot.kind === 'inspection') delete legacy.snapshot.inspector.thumbnailUrl; + const olderAccount = env.USER_ACCOUNT.getByName(await userAccountInstanceName('legacy-thumbnail')); + const olderEntry = await olderAccount.saveSource(legacy); + expect((await olderAccount.listSources())[0]?.thumbnailUrl).toBeUndefined(); + const list = await sourceApp('legacy-thumbnail').request('/sources/recent', {}, env); + expect(await list.json()).toMatchObject({ sources: [{ id: olderEntry.id, thumbnailUrl }] }); + expect((await olderAccount.listSources())[0]?.thumbnailUrl).toBe(thumbnailUrl); + expect((await olderAccount.listSources())[0]?.updatedAt).toBe(olderEntry.updatedAt); expect(await second.getSource(saved.id)).toBeNull(); await second.saveSource(references); await runInDurableObject(first, (_instance, state) => { diff --git a/reference/engineering/SOURCE_HISTORY.md b/reference/engineering/SOURCE_HISTORY.md index ab060d5..fead40c 100644 --- a/reference/engineering/SOURCE_HISTORY.md +++ b/reference/engineering/SOURCE_HISTORY.md @@ -30,3 +30,7 @@ The browser-session routes are `GET /v1/sources/recent`, `POST /v1/sources/recen Account deletion removes the user's references along with the other user DO data. Shared assets remain available to other accounts. Pruning the oldest entries removes only user references. Shared response objects follow the catalog's existing policy of retaining public assets, with no blanket bucket expiration or garbage collector. No new Cloudflare binding or class migration is required. `UserAccountDO` creates the history table when initialized. The platform and web changes must both be deployed to enable the feature. + +Recent inspection summaries carry a thumbnail URL reference from saved metadata. Older entries resolve that reference from their existing catalog version on the first list read. The user DO caches the URL without changing history order or copying image bytes. Missing thumbnails do not block the history list. + +Selecting Sources in the sidebar opens the input and recent list, clears the displayed query or inspector, and cancels its outstanding browser request. Ordinary navigation away from Sources still allows requests to finish in the background. The history loader uses shared skeleton bars within rows that match the loaded layout. diff --git a/web/app/dashboard/DashboardSidebar.tsx b/web/app/dashboard/DashboardSidebar.tsx index 1ea8e73..b770ded 100644 --- a/web/app/dashboard/DashboardSidebar.tsx +++ b/web/app/dashboard/DashboardSidebar.tsx @@ -3,7 +3,7 @@ import { useAccountResource } from './DashboardDataProvider'; import Link from 'next/link'; -import { dashboardPath } from './dashboard-routes'; +import { dashboardPath, SOURCES_HOME_EVENT } from './dashboard-routes'; import { useDashboardSession } from './DashboardSessionProvider'; import { useEffect, useRef, useState, type ReactNode } from 'react'; import { SidebarSimpleIcon, KeyIcon, BookOpenIcon, CoinsIcon, SignOutIcon, CaretDownIcon, ListIcon, XIcon } from '@phosphor-icons/react'; @@ -68,7 +68,10 @@ export function DashboardSidebar({ activeSection }; const run = (action: () => void) => { dialog.current?.close(); action(); }; const navButton = (section: DashboardSection, label: string, icon: IconName, suffix?: ReactNode) => ( - dialog.current?.close()}> + { + dialog.current?.close(); + if (section === 'discover' && event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) window.dispatchEvent(new Event(SOURCES_HOME_EVENT)); + }}> {label}{suffix} ); diff --git a/web/app/dashboard/HistoryEmptyState.module.css b/web/app/dashboard/HistoryEmptyState.module.css new file mode 100644 index 0000000..c017ab2 --- /dev/null +++ b/web/app/dashboard/HistoryEmptyState.module.css @@ -0,0 +1,3 @@ +.empty { padding: 34px 8px; } +.empty h3 { margin: 0 0 12px; font: 600 18px/1.4 var(--font-dashboard-body); color: var(--color-dashboard-ink); } +.empty p { margin: 0; font-size: 13px; line-height: 1.7; color: var(--color-dashboard-muted); } diff --git a/web/app/dashboard/HistoryEmptyState.tsx b/web/app/dashboard/HistoryEmptyState.tsx new file mode 100644 index 0000000..d611bce --- /dev/null +++ b/web/app/dashboard/HistoryEmptyState.tsx @@ -0,0 +1,5 @@ +import styles from './HistoryEmptyState.module.css'; + +export function HistoryEmptyState({ title, description }: { title: string; description: string }) { + return

{title}

{description}

; +} diff --git a/web/app/dashboard/SourcesClient.tsx b/web/app/dashboard/SourcesClient.tsx index 980f8f0..75f544e 100644 --- a/web/app/dashboard/SourcesClient.tsx +++ b/web/app/dashboard/SourcesClient.tsx @@ -7,12 +7,14 @@ import { platformRequest as api, isAbortError } from '../../lib/platform-request import { loadSourceData, videoIdFromInput } from '../../lib/source-data'; import { Checkbox } from './Checkbox'; +import { HistoryEmptyState } from './HistoryEmptyState'; import { useAccountResource, useDashboardDraft, useDashboardCache } from './DashboardDataProvider'; import pageStyles from './DashboardPages.module.css'; import { Icon } from './DashboardSidebar'; import { useDashboardSession } from './DashboardSessionProvider'; +import { SOURCES_HOME_EVENT } from './dashboard-routes'; import type { ProviderId, EntityType, SourceDataOption, Thumbnail, SearchItem, Segment, Transcript, CommentPage, ChannelInfo, Project, Inspector, RecentSource, SourceSnapshot } from './research-types'; import { DashboardSkeleton as SourceSkeleton } from './DashboardSkeleton'; @@ -137,6 +139,16 @@ export default function SourcesClient({ active }: {active:boolean}) { } }, []); + const showRecentSources = useCallback(() => { + cancelOperation(); setInspector(null); setItems([]); setHasSearched(false); + setQuery(''); setTranscriptQuery(''); setError(''); setNotice(''); + }, [cancelOperation, setInspector, setItems, setHasSearched, setQuery, setTranscriptQuery]); + + useEffect(() => { + window.addEventListener(SOURCES_HOME_EVENT, showRecentSources); + return () => window.removeEventListener(SOURCES_HOME_EVENT, showRecentSources); + }, [showRecentSources]); + useEffect(() => () => { operationController.current?.abort(); }, []); useEffect(() => { if (!active) return; @@ -355,7 +367,7 @@ export default function SourcesClient({ active }: {active:boolean}) {
<>
-

Search or paste a YouTube link

{(inspector || hasSearched) && }
+

Search or paste a YouTube link

{(inspector || hasSearched) && }
@@ -393,11 +405,11 @@ export default function SourcesClient({ active }: {active:boolean}) { hasSearched || loading || items.length ? void inspect('video', id, undefined, provider, selectedData)} onStart={() => searchInput.current?.focus()} loading={loading} hasSearched={hasSearched} failed={Boolean(error)} /> :

Recent sources

- {historyLoading ?

Loading recent sources…

: null} + {historyLoading && !recentSources.length ? : null} {historyError ?
{historyError}
: null} - {!historyLoading && !historyError && !recentSources.length ?

No recent sources yet

Search YouTube or inspect a link to start your history.

: null} -
{recentSources.map(source => )}
@@ -410,6 +422,17 @@ export default function SourcesClient({ active }: {active:boolean}) { ); } +function RecentSourcesSkeleton() { + return
+ Loading recent sources + {[0, 1, 2].map(index => )} +
; +} + function SourceChannelSkeleton() { return