Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/api-reference/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8490,6 +8490,11 @@
},
"updatedAt": {
"type": "integer"
},
"thumbnailUrl": {
"type": "string",
"format": "uri",
"description": "Saved thumbnail reference for an inspected URL, when available."
}
}
},
Expand Down
21 changes: 17 additions & 4 deletions platform/src/durable-objects/user-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ export class UserAccountDO extends DurableObject<Env> {
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
Expand All @@ -133,9 +134,21 @@ export class UserAccountDO extends DurableObject<Env> {

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 {
Expand Down
21 changes: 21 additions & 0 deletions platform/src/lib/source-history-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, unknown>): 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<string | undefined> {
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<Record<string, unknown>>(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<Record<string, unknown>>());
}
}

// Only public provider payloads enter this bucket. Inputs and user selections stay in the user DO.
async function saveShared(env: Env, value: unknown): Promise<string> {
const payload = JSON.stringify(value);
Expand Down Expand Up @@ -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),
},
} };
}
Expand Down
3 changes: 2 additions & 1 deletion platform/src/lib/source-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof sourceReferenceSchema>;
export type SaveReferencedSource = z.infer<typeof saveReferencedSourceSchema>;
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 {
Expand Down
1 change: 1 addition & 0 deletions platform/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
13 changes: 11 additions & 2 deletions platform/src/routes/session/session.index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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) => {
Expand Down
1 change: 1 addition & 0 deletions platform/test/e2e/agent-sessions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
104 changes: 100 additions & 4 deletions platform/test/e2e/dashboard-api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand All @@ -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: '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180"><rect width="320" height="180" fill="#334155"/></svg>' }));
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<void>(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<void>(resolve => { release = resolve; });
Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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<void>(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');
Expand All @@ -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);
});

Expand Down
Loading
Loading