diff --git a/platform/test/e2e/dashboard-api.spec.ts b/platform/test/e2e/dashboard-api.spec.ts index 4fbe2b2..52af3a2 100644 --- a/platform/test/e2e/dashboard-api.spec.ts +++ b/platform/test/e2e/dashboard-api.spec.ts @@ -427,6 +427,37 @@ test('settings cards load independently and preserve their layout', async ({ pag } finally { await scenario.clear(); } }); +test('notification panel stays opaque and above the Sources form on a narrow screen', async ({ page }, testInfo) => { + const scenario = await accountScenario(page, { responses: { '/v1/notifications': { body: { notifications: Array.from({ length: 5 }, (_, index) => ({ + id: `notice-${index}`, type: 'monitor', title: 'New video', body: 'A monitor found a match', data_json: '{}', + read_at: null, created_at: Date.now() - index * 60_000, + })) } } } }); + try { + await page.setViewportSize({ width: 491, height: 610 }); + await page.goto('/dashboard/sources'); + const trigger = page.getByRole('button', { name: '5 unread notifications' }); + await trigger.click(); + const panel = page.getByRole('dialog', { name: 'Notifications' }); + await expect(panel).toBeVisible(); + const panelBox = (await panel.boundingBox())!; + const inspectBox = (await page.getByRole('button', { name: /Inspect/ }).boundingBox())!; + const left = Math.max(panelBox.x, inspectBox.x); + const right = Math.min(panelBox.x + panelBox.width, inspectBox.x + inspectBox.width); + const top = Math.max(panelBox.y, inspectBox.y); + const bottom = Math.min(panelBox.y + panelBox.height, inspectBox.y + inspectBox.height); + expect(right).toBeGreaterThan(left); + expect(bottom).toBeGreaterThan(top); + const x = (left + right) / 2, y = (top + bottom) / 2; + await page.screenshot({ path: testInfo.outputPath('notifications-over-sources.png') }); + const overlay = await page.evaluate<{ panelIsTopmost: boolean; background: string }>(`({ + panelIsTopmost: Boolean(document.elementFromPoint(${x}, ${y})?.closest('.notification-popover')), + background: getComputedStyle(document.querySelector('.notification-popover')).backgroundColor, + })`); + expect(overlay.panelIsTopmost).toBe(true); + expect(overlay.background).not.toMatch(/\/\s*0(?:\.0+)?\)/); + } finally { await scenario.clear(); } +}); + test('dashboard navigation reuses account data without browser refetches', async ({ page }) => { const scenario = await accountScenario(page, {}); const reads: string[] = []; @@ -545,28 +576,67 @@ test('warm settings stays usable while a return visit server read is delayed', a } finally { await scenario.clear(); } }); -test('settings sidebar opens the selected project and the new-project dialog', async ({ page }) => { +test('settings sidebar expands project sources and opens the new-project dialog', async ({ page }) => { const scenario = await accountScenario(page, { responses: { '/v1/projects': { body: { projects: [{ id: 'research', name: 'Saved research' }] } } } }); try { - await page.route('**/api/platform/v1/projects/research', route => route.fulfill({ json: { id: 'research', name: 'Saved research', items: [] } })); + await page.route('**/api/platform/v1/projects/research', route => route.fulfill({ json: { id: 'research', name: 'Saved research', items: [{ id: 'item-1', provider: 'youtube', entity_type: 'video', entity_id: videoId, title: 'Sample video' }] } })); await page.goto('/dashboard/settings'); - await page.getByRole('button', { name: 'Saved research', exact: true }).click(); + const folder = page.getByRole('button', { name: 'Saved research', exact: true }).first(); + await folder.click(); + await expect(folder).toHaveAttribute('aria-expanded', 'true'); + await expect(page.getByRole('group', { name: 'Sources in Saved research' }).first().getByRole('button', { name: 'Sample video' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Saved research', exact: true })).toBeVisible(); + await folder.click(); + await expect(folder).toHaveAttribute('aria-expanded', 'false'); + await expect(page.getByRole('group', { name: 'Sources in Saved research' }).first()).toHaveCount(0); await page.getByRole('link', { name: 'Settings', exact: true }).click(); await page.getByRole('button', { name: 'Create a new project', exact: true }).first().click(); - await expect(page.getByRole('dialog', { name: 'Name this line of inquiry' })).toBeVisible(); + await expect(page.getByRole('dialog', { name: 'New project' })).toBeVisible(); } finally { await scenario.clear(); } }); +test('project creation shows progress and adds the project without another list request', async ({ page }) => { + const scenario = await accountScenario(page, { responses: { '/v1/projects': { body: { projects: [] } } } }); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + let creates = 0; + await page.route('**/api/platform/v1/projects', async route => { + if (route.request().method() !== 'POST') return route.continue(); + creates++; + await gate; + return route.fulfill({ status: 201, json: { id: 'new-research', name: 'Video research' } }); + }); + try { + await page.goto('/dashboard/projects'); + const readsBefore = (await scenario.reads())['/v1/projects'] ?? 0; + await page.getByRole('button', { name: 'New project', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: 'New project' }); + await expect(dialog.getByText('Keep related sources in one place.')).toBeVisible(); + await expect(dialog.getByRole('button', { name: 'Create', exact: true })).toBeDisabled(); + await dialog.getByRole('textbox', { name: 'Name' }).fill('Video research'); + await dialog.getByRole('button', { name: 'Create', exact: true }).click(); + await expect(dialog).toHaveAttribute('aria-busy', 'true'); + await expect(dialog.getByRole('button', { name: 'Creating…' })).toBeDisabled(); + expect(creates).toBe(1); + release(); + await expect(dialog).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Video research', exact: true }).first()).toBeVisible(); + expect((await scenario.reads())['/v1/projects'] ?? 0).toBe(readsBefore); + } finally { release(); await scenario.clear(); } +}); + test('settings renders while navigation access checks are pending', async ({page})=>{ const scenario=await accountScenario(page,{delays:['/v1/agent/access','/v1/admin/access']}); try{ await page.goto('/dashboard/settings',{waitUntil:'commit'}); await expect(page.getByRole('switch',{name:/In-app alerts/})).toBeEnabled(); await expect(page.getByRole('button',{name:'Upgrade to Builder'})).toBeEnabled(); + const navigation = page.getByRole('navigation', { name: 'Dashboard navigation' }); await expect(page.getByRole('link',{name:'Agent',exact:true})).toHaveCount(0); + await expect.poll(async () => (await navigation.locator('a').allTextContents()).slice(0, 2).map(label => label.trim())).toEqual(['Sources', 'Trends']); await scenario.release(); await expect(page.getByRole('link',{name:'Agent',exact:true})).toBeVisible(); + await expect.poll(async () => (await navigation.locator('a').allTextContents()).slice(0, 3).map(label => label.trim())).toEqual(['Sources', 'Agent', 'Trends']); }finally{await scenario.clear();} }); @@ -599,7 +669,7 @@ test('an active trend request survives projects navigation without restarting',a await page.getByRole('link',{name:'Projects',exact:true}).click(); await expect(page.getByRole('heading',{name:'Your projects'})).toBeVisible(); release(); - await page.getByRole('link',{name:'Trend Lab',exact:true}).click(); + await page.getByRole('link',{name:'Trends',exact:true}).click(); await expect(page.getByRole('alert').filter({hasText:'Retained scan completed'})).toBeVisible(); expect(reads).toBe(1); }); diff --git a/platform/test/e2e/login.spec.ts b/platform/test/e2e/login.spec.ts index 224aeef..5771ebd 100644 --- a/platform/test/e2e/login.spec.ts +++ b/platform/test/e2e/login.spec.ts @@ -16,6 +16,10 @@ test('signed-out dashboard visits redirect to login and preserve the destination await page.goto('/login?returnTo=%2Fdashboard%2Fdeveloper'); await expect(page).toHaveURL('/dashboard/developer'); await page.goto('/login?returnTo=https%3A%2F%2Fevil.test'); + await expect(page).toHaveURL('/dashboard/sources'); + await page.goto('/dashboard'); + await expect(page).toHaveURL('/dashboard/sources'); + await page.goto('/dashboard?section=trends'); await expect(page).toHaveURL('/dashboard/trends'); }); diff --git a/web/app/dashboard/DashboardSidebar.module.css b/web/app/dashboard/DashboardSidebar.module.css index 4fcc910..6ffd36c 100644 --- a/web/app/dashboard/DashboardSidebar.module.css +++ b/web/app/dashboard/DashboardSidebar.module.css @@ -59,8 +59,19 @@ .projectHeading { display: flex; align-items: center; justify-content: space-between; padding-left: .25rem; } .projectHeading button { width: 1.75rem; height: 1.75rem; } .project { display: flex; align-items: center; gap: .625rem; width: 100%; border: 0; border-radius: .375rem; padding: .5rem .375rem; color: var(--color-dashboard-ink-soft); background: transparent; font: inherit; font-size: .75rem; text-align: left; cursor: pointer; } -.project > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.projectDot { width: .375rem; height: .375rem; flex-shrink: 0; border-radius: 50%; background: var(--color-dashboard-accent); } +.project > svg { flex: 0 0 1.0625rem; color: var(--color-dashboard-muted); } +.project[aria-expanded='true'] { color: var(--color-dashboard-ink); } +.project[aria-expanded='true'] > svg { color: var(--color-dashboard-accent); } +.projectName { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.projectItems { display: grid; gap: .125rem; margin: .125rem 0 .375rem 1rem; padding-left: .625rem; border-left: 1px solid var(--color-dashboard-rule); } +.projectItem { display: flex; align-items: center; gap: .5rem; min-width: 0; width: 100%; padding: .375rem .5rem; border: 0; border-radius: .375rem; background: transparent; color: var(--color-dashboard-ink-soft); font: inherit; font-size: .6875rem; text-align: left; cursor: pointer; } +.projectItem:hover { background: var(--color-dashboard-surface-muted); color: var(--color-dashboard-ink); } +.projectItem > svg { flex: 0 0 .875rem; color: var(--color-dashboard-muted); } +.projectItem > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.projectEmpty { padding: .375rem .5rem; color: var(--color-dashboard-muted); font-size: .6875rem; } +.projectItemSkeleton { display: grid; gap: .5rem; padding: .5rem; } +.projectItemSkeleton > i { width: 75%; height: .625rem; } +.projectItemSkeleton > i:last-child { width: 55%; } .projects p { margin: .375rem .25rem; color: var(--color-dashboard-muted); font-size: .6875rem; line-height: 1.6; } .quickCreate { display: none; } .footer { display: grid; gap: .25rem; padding-top: .5rem; } diff --git a/web/app/dashboard/DashboardSidebar.tsx b/web/app/dashboard/DashboardSidebar.tsx index b770ded..760a23a 100644 --- a/web/app/dashboard/DashboardSidebar.tsx +++ b/web/app/dashboard/DashboardSidebar.tsx @@ -1,12 +1,14 @@ 'use client'; -import { useAccountResource } from './DashboardDataProvider'; +import { useAccountResource, useDashboardDraft } from './DashboardDataProvider'; import Link from 'next/link'; 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'; +import { SidebarSimpleIcon, KeyIcon, BookOpenIcon, CoinsIcon, SignOutIcon, CaretDownIcon, ListIcon, XIcon, FolderIcon, FolderOpenIcon } from '@phosphor-icons/react'; +import { platformRequest as api, isAbortError } from '../../lib/platform-request'; +import type { ProjectDetail, ProjectItem } from './research-types'; import styles from './DashboardSidebar.module.css'; export type DashboardSection = 'trends' | 'discover' | 'projects' | 'monitors' | 'settings'; @@ -38,6 +40,7 @@ type DashboardSidebarProps = { onNavigate: (section: DashboardSection) => void; onNewProject: () => void; onOpenProject: (project: Project) => void; + onOpenProjectItem: (item: ProjectItem) => void; onSignIn: () => void; accountName?: string; credits?: number; @@ -46,13 +49,28 @@ type DashboardSidebarProps = { const COLLAPSED_KEY = 'video2ctx.sidebar.collapsed'; -export function DashboardSidebar({ activeSection, projects, onNavigate, onNewProject, onOpenProject, onSignIn, accountName, credits, onSignOut }: DashboardSidebarProps) { +export function DashboardSidebar({ activeSection, projects, onNavigate, onNewProject, onOpenProject, onOpenProjectItem, onSignIn, accountName, credits, onSignOut }: DashboardSidebarProps) { const { agentAccess, adminAccess, isSigningOut } = useDashboardSession(); const projectsResource = useAccountResource('projects', []); const [collapsed, setCollapsed] = useState(false); const [mobileOpen, setMobileOpen] = useState(false); + const [expandedProjectId, setExpandedProjectId] = useDashboardDraft('expanded-sidebar-project', null); + const [projectDetail, setProjectDetail] = useState(null); + const [projectError, setProjectError] = useState(''); + const [retryProject, setRetryProject] = useState(0); const dialog = useRef(null); + useEffect(() => { + if (!expandedProjectId || !projects.some(project => project.id === expandedProjectId)) return; + const controller = new AbortController(); + setProjectDetail(null); + setProjectError(''); + void api(`/v1/projects/${encodeURIComponent(expandedProjectId)}`, { signal: controller.signal }) + .then(setProjectDetail) + .catch(cause => { if (!isAbortError(cause)) setProjectError(cause instanceof Error ? cause.message : 'Could not load saved sources.'); }); + return () => controller.abort(); + }, [expandedProjectId, projects, retryProject]); + useEffect(() => { try { setCollapsed(localStorage.getItem(COLLAPSED_KEY) === 'true'); } catch { /* Storage may be disabled. */ } const desktop = window.matchMedia('(min-width: 701px)'); @@ -67,6 +85,12 @@ export function DashboardSidebar({ activeSection try { localStorage.setItem(COLLAPSED_KEY, String(next)); } catch { /* Keep the toggle usable without storage. */ } }; const run = (action: () => void) => { dialog.current?.close(); action(); }; + const toggleProject = (project: Project) => { + if (expandedProjectId === project.id) { setExpandedProjectId(null); return; } + setProjectDetail(null); + setExpandedProjectId(project.id); + onOpenProject(project); + }; const navButton = (section: DashboardSection, label: string, icon: IconName, suffix?: ReactNode) => ( { dialog.current?.close(); @@ -87,9 +111,9 @@ export function DashboardSidebar({ activeSection
Recent projects
- {projects.slice(0, 5).map(project => )} - {!projectsResource.ready && !projectsResource.error &&
{[0, 1, 2].map(index => )}
} + {projects.slice(0, 5).map(project => { + const expanded = expandedProjectId === project.id; + return
+ + {expanded &&
+ {!projectDetail && !projectError &&
} + {projectError && } + {projectDetail?.id === project.id && (projectDetail.items.length + ? projectDetail.items.map(item => ) + : No saved sources)} +
} +
; + })} + {!projectsResource.ready && !projectsResource.error &&
{[0, 1, 2].map(index => )}
} {projectsResource.ready && !projects.length &&

Save a source to start a project.

}
diff --git a/web/app/dashboard/NewProjectDialog.tsx b/web/app/dashboard/NewProjectDialog.tsx index b992b79..08b54f8 100644 --- a/web/app/dashboard/NewProjectDialog.tsx +++ b/web/app/dashboard/NewProjectDialog.tsx @@ -1,9 +1,54 @@ 'use client'; -import { useRef, useEffect, useState } from 'react'; -export function NewProjectDialog({ onClose,onCreate }: { onClose:()=>void;onCreate:(name:string)=>void }) { const dialogRef=useDialogFocus(onClose);const [name,setName]=useState('');return
event.stopPropagation()} onSubmit={(event)=>{event.preventDefault();if(name.trim())onCreate(name.trim());}}>

New project

Name this line of inquiry

setName(event.target.value)} placeholder='e.g. AI video research'/>
; } + +import { useEffect, useRef, useState } from 'react'; + +export function NewProjectDialog({ onClose, onCreate, error }: { + onClose: () => void; + onCreate: (name: string) => Promise; + error: string; +}) { + const [name, setName] = useState(''); + const [creating, setCreating] = useState(false); + const close = () => { if (!creating) onClose(); }; + const dialogRef = useDialogFocus(close); + + const create = async () => { + const trimmed = name.trim(); + if (!trimmed || creating) return; + setCreating(true); + const succeeded = await onCreate(trimmed); + if (!succeeded) setCreating(false); + }; + + return
+
event.stopPropagation()} + onSubmit={event => { event.preventDefault(); void create(); }} + > + +

New project

+

Keep related sources in one place.

+ + setName(event.target.value)} placeholder='e.g. AI video research' /> + {error &&

{error}

} + +
+
; +} function useDialogFocus(onClose: () => void) { const dialogRef = useRef(null); + const closeRef = useRef(onClose); + closeRef.current = onClose; useEffect(() => { const previouslyFocused = document.activeElement as HTMLElement | null; const dialog = dialogRef.current; @@ -12,9 +57,9 @@ function useDialogFocus(onClose: () => void) { preferred?.focus(); }); const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { event.preventDefault(); onClose(); return; } + if (event.key === 'Escape') { event.preventDefault(); closeRef.current(); return; } if (event.key !== 'Tab' || !dialog) return; - const focusable = Array.from(dialog.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])')).filter((element) => !element.hidden); + const focusable = Array.from(dialog.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])')).filter(element => !element.hidden); if (!focusable.length) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } @@ -22,6 +67,6 @@ function useDialogFocus(onClose: () => void) { }; document.addEventListener('keydown', onKeyDown); return () => { document.removeEventListener('keydown', onKeyDown); previouslyFocused?.focus(); }; - }, [onClose]); + }, []); return dialogRef; } diff --git a/web/app/dashboard/ResearchHost.tsx b/web/app/dashboard/ResearchHost.tsx index b416346..924bb24 100644 --- a/web/app/dashboard/ResearchHost.tsx +++ b/web/app/dashboard/ResearchHost.tsx @@ -48,7 +48,7 @@ export function ResearchHost() { )} {(visited.trend || trend) && (