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 @@