diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index 82aeb039..bf8274b9 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -30,6 +30,7 @@ Browser routes served by the SPA shell: - `/` → `web/src/routes/SessionsPage.svelte` - `/session?id=…` → `web/src/routes/SessionPage.svelte` +- `/schedules` → `web/src/routes/SchedulesPage.svelte` - `/settings` → `web/src/routes/SettingsPage.svelte` - `/login` → `web/src/routes/LoginPage.svelte` diff --git a/e2e/tests/schedules.spec.ts b/e2e/tests/schedules.spec.ts index 7260a9bc..12271c7a 100644 --- a/e2e/tests/schedules.spec.ts +++ b/e2e/tests/schedules.spec.ts @@ -46,6 +46,19 @@ test.describe("schedules (stubbed pi)", () => { expect(maxWidth).toBe("880px"); }); + test("session header button opens the schedules page", async ({ page }) => { + await page.goto("/"); + await page.locator("[data-sessions-content].index-layout-ready").waitFor(); + await page + .locator(".session-card", { hasText: "add deepseek-v4-pro" }) + .click(); + await expect(page).toHaveURL(/\/session\?id=/); + + await page.locator(".session-header-bar [data-schedules-btn]").click(); + await expect(page).toHaveURL(/\/schedules$/); + await expect(page.locator(".schedules-page")).toBeVisible(); + }); + test("create, run now, view run log, and delete a schedule", async ({ page, }, testInfo) => { diff --git a/internal/ui/embedded/styles/session.css b/internal/ui/embedded/styles/session.css index e2892975..a4752645 100644 --- a/internal/ui/embedded/styles/session.css +++ b/internal/ui/embedded/styles/session.css @@ -2608,7 +2608,8 @@ border-color: var(--pi-menu-border); } - .session-header-new { + .session-header-new, + .session-header-schedules { height: 24px; padding: 0 9px 0 7px; font-size: 11px; @@ -2617,13 +2618,16 @@ display: inline-flex; align-items: center; gap: 3px; - background: var(--accent); - color: var(--bg); - border: 1px solid var(--accent); border-radius: 4px; cursor: pointer; flex-shrink: 0; margin-right: 8px; + } + + .session-header-new { + background: var(--accent); + color: var(--bg); + border: 1px solid var(--accent); transition: opacity 0.12s; } @@ -2637,18 +2641,25 @@ opacity: 0.85; } - @media (max-width: 900px) { - .session-header-new { - padding: 0; - width: 28px; - height: 28px; - justify-content: center; - gap: 0; - } + .session-header-schedules { + background: color-mix(in srgb, var(--surface) 54%, transparent); + color: var(--muted); + border: 1px solid color-mix(in srgb, var(--dim) 76%, transparent); + text-decoration: none; + transition: color 0.12s, background 0.12s, border-color 0.12s; + } - .session-header-new-label { - display: none; - } + .session-header-schedules-icon { + display: inline-flex; + align-items: center; + color: var(--accent); + } + + .session-header-schedules:hover, + .session-header-schedules:active { + color: var(--text); + background: var(--pi-menu-bg); + border-color: var(--pi-menu-border); } .session-header-shortcuts-help { @@ -2678,6 +2689,20 @@ } @media (max-width: 900px) { + .session-header-new, + .session-header-schedules { + padding: 0; + width: 28px; + height: 28px; + justify-content: center; + gap: 0; + } + + .session-header-new-label, + .session-header-schedules-label { + display: none; + } + .session-header-shortcuts-help { display: none; } diff --git a/web/src/components/session/CommandMenu.svelte b/web/src/components/session/CommandMenu.svelte index 33574fec..144824f9 100644 --- a/web/src/components/session/CommandMenu.svelte +++ b/web/src/components/session/CommandMenu.svelte @@ -19,12 +19,13 @@ ChartColumn, BookOpen, Send, + CalendarClock, Settings, Tag, } from '../../shared/icons.js'; import * as sidebarApi from '../../session/ui/sidebar.js'; import { openVersionModal } from '../../shared/version.js'; - import { navigate } from '../../shared/navigation.js'; + import { navigate, handleNavClick } from '../../shared/navigation.js'; import { openSessionPalette } from '../../shared/command-palette-runtime.js'; import { openModelUsage, openFork, openDiff } from '../../session/session-modals.svelte.js'; import { showToast } from '../../shared/toast.js'; @@ -68,6 +69,7 @@ icon: Send, label: 'common.telegram', }, + { kind: 'link', href: '/schedules', icon: CalendarClock, label: 'schedules.navTitle' }, { kind: 'link', href: '/settings', icon: Settings, label: 'common.settings', kbd: '⌘,' }, { kind: 'version', action: 'version', icon: Tag, label: 'common.version', desktopOnly: true }, ]; @@ -77,6 +79,11 @@ const clickHidden = (id) => document.getElementById(id)?.click(); const isMobile = () => sidebarApi.isMobileLayout(); + // Filled in onMount. Footer links call this before SPA navigation so the + // popover does not linger if the session page ever stops unmounting + // (HomeMenu closes the same way). + let closeMenu = () => {}; + onMount(() => { const menuBtn = document.getElementById('command-menu-btn'); const desktopPopover = document.getElementById('command-menu-popover'); @@ -125,12 +132,13 @@ if (isMobile()) openMobilePanel(); else openDesktopPopover(); }; - const closeMenu = () => { + const hideMenu = () => { open = false; menuBtn.setAttribute('aria-expanded', 'false'); closeMobilePanel(); closeDesktopPopover(); }; + closeMenu = hideMenu; function handleAction(action) { switch (action) { @@ -244,14 +252,15 @@ const containers = [mobilePanel, desktopPopover].filter(Boolean); menuBtn.addEventListener('click', onMenuBtnClick); - mobileBackdrop?.addEventListener('click', closeMenu); + mobileBackdrop?.addEventListener('click', hideMenu); document.addEventListener('click', onDocClick); document.addEventListener('keydown', onKey); containers.forEach((c) => c.addEventListener('click', onContainerClick)); return () => { + closeMenu = () => {}; menuBtn.removeEventListener('click', onMenuBtnClick); - mobileBackdrop?.removeEventListener('click', closeMenu); + mobileBackdrop?.removeEventListener('click', hideMenu); document.removeEventListener('click', onDocClick); document.removeEventListener('keydown', onKey); containers.forEach((c) => c.removeEventListener('click', onContainerClick)); @@ -283,6 +292,11 @@ role="menuitem" target={item.external ? '_blank' : undefined} rel={item.external ? 'noreferrer' : undefined} + onclick={(event) => { + if (item.external) return; + closeMenu(); + handleNavClick(event, item.href); + }} >{@render label(item)}{#if desktop && item.kbd}{item.kbd}{/if} {:else if item.kind === 'version'} diff --git a/web/src/components/session/CommandMenu.test.js b/web/src/components/session/CommandMenu.test.js index a94c5031..ccfc5a0b 100644 --- a/web/src/components/session/CommandMenu.test.js +++ b/web/src/components/session/CommandMenu.test.js @@ -61,6 +61,24 @@ describe('CommandMenu', () => { expect(sessionTitle.name).toBe('Old'); }); + it('closes the actions menu before navigating to /schedules', async () => { + const pushState = vi.spyOn(window.history, 'pushState'); + render(CommandMenu, { props: { sessionId: 's' } }); + await tick(); + + const menuBtn = document.getElementById('command-menu-btn'); + await fireEvent.click(menuBtn); + expect(menuBtn.getAttribute('aria-expanded')).toBe('true'); + + const link = document.querySelector('#command-menu-popover a[href="/schedules"]'); + expect(link).toBeTruthy(); + expect(link.textContent).toContain('Schedules'); + + await fireEvent.click(link); + expect(pushState).toHaveBeenCalledWith({}, '', '/schedules'); + expect(menuBtn.getAttribute('aria-expanded')).toBe('false'); + }); + it('opens model usage via the modal store + the session-list palette runtime', async () => { const openPalette = vi.fn(); render(CommandMenu, { props: { sessionId: 's' } }); diff --git a/web/src/components/session/SessionHeader.svelte b/web/src/components/session/SessionHeader.svelte index f88c374e..29f84e93 100644 --- a/web/src/components/session/SessionHeader.svelte +++ b/web/src/components/session/SessionHeader.svelte @@ -1,6 +1,13 @@