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
1 change: 1 addition & 0 deletions docs/architecture/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
13 changes: 13 additions & 0 deletions e2e/tests/schedules.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
55 changes: 40 additions & 15 deletions internal/ui/embedded/styles/session.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand Down
22 changes: 18 additions & 4 deletions web/src/components/session/CommandMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 },
];
Expand All @@ -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');
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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}<kbd>{item.kbd}</kbd>{/if}</a
>
{:else if item.kind === 'version'}
Expand Down
18 changes: 18 additions & 0 deletions web/src/components/session/CommandMenu.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' } });
Expand Down
20 changes: 19 additions & 1 deletion web/src/components/session/SessionHeader.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
<script>
import { onMount } from 'svelte';
import { icon, PanelLeft, Plus, SquarePen, MoreHorizontal } from '../../shared/icons.js';
import {
icon,
CalendarClock,
PanelLeft,
Plus,
SquarePen,
MoreHorizontal,
} from '../../shared/icons.js';
import { t } from '../../shared/i18n.js';
import { navigate, handleNavClick } from '../../shared/navigation.js';
import { showToast } from '../../shared/toast.js';
Expand Down Expand Up @@ -109,6 +116,17 @@
</div>
<span class="session-header-title" id="session-header-title">{sessionTitle.name || title}</span>
<div class="session-header-right">
<a
href="/schedules"
class="session-header-schedules"
data-schedules-btn
title={t('schedules.navTitle')}
aria-label={t('schedules.navTitle')}
onclick={(event) => handleNavClick(event, '/schedules')}
><span class="session-header-schedules-icon" aria-hidden="true"
>{@html icon(CalendarClock, { size: 14 })}</span
><span class="session-header-schedules-label">{t('schedules.navTitle')}</span></a
>
<button
id="new-session-header-btn"
class="session-header-new"
Expand Down
30 changes: 30 additions & 0 deletions web/src/components/session/SessionHeader.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import SessionHeader from './SessionHeader.svelte';

afterEach(() => {
vi.restoreAllMocks();
});

describe('SessionHeader', () => {
it('navigates to /schedules from the header control', async () => {
const user = userEvent.setup();
const pushState = vi.spyOn(window.history, 'pushState');
render(SessionHeader, {
props: { title: 'How', cwd: '/tmp', sessionId: 's.jsonl', sessionUUID: 'uuid' },
});

const link = document.querySelector('[data-schedules-btn]');
expect(link).toBeTruthy();
expect(link.getAttribute('href')).toBe('/schedules');
expect(link.textContent).toContain('Schedules');

const right = document.querySelector('.session-header-right');
expect(right?.children[0]).toBe(link);
expect(right?.children[1]?.id).toBe('new-session-header-btn');

await user.click(link);
expect(pushState).toHaveBeenCalledWith({}, '', '/schedules');
});
});