From 3d4c6a52e4add4ddcde88495faf93c3eae8df43d Mon Sep 17 00:00:00 2001 From: setkyar Date: Thu, 17 Sep 2026 17:11:17 +0700 Subject: [PATCH] feat(session): add Manage Projects to the session header, fix Schedules back The manage-projects sheet was reachable only from the index menu, and the Schedules page's back button always went to the index even when opened from a session. Session header now carries a Manage Projects control next to the tree toggle that opens the same ProjectsModal the index uses. Toggling a project bumps a revision that remounts the sidebar's PROJECTS tab, which fetches with filtered=true and would otherwise show the old enabled set. /schedules is reachable from the index and from a session, so its entry points push { back: } as history state and the back button follows it, falling back to the index for direct deep links. --- docs/architecture/frontend.md | 4 +- e2e/tests/schedules.spec.ts | 4 + internal/ui/embedded/styles/session.css | 24 ++++-- web/src/components/session/CommandMenu.svelte | 8 +- .../components/session/CommandMenu.test.js | 2 +- .../components/session/SessionHeader.svelte | 17 ++++- .../components/session/SessionHeader.test.js | 20 ++++- .../components/session/SessionShell.svelte | 73 ++++++++++++++++++- web/src/components/session/SessionTree.svelte | 17 +++-- web/src/routes/SchedulesPage.svelte | 14 +++- web/src/routes/SchedulesPage.test.js | 42 +++++++++++ web/src/routes/SessionsPage.svelte | 4 +- web/src/session/session-modals.svelte.js | 6 ++ web/src/shared/navigation.js | 19 ++++- 14 files changed, 225 insertions(+), 29 deletions(-) create mode 100644 web/src/routes/SchedulesPage.test.js diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index e876efeb..83459c0b 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -36,9 +36,11 @@ Browser routes served by the SPA shell: API, SSE, PWA, sound, and static asset routes remain server-handled and are not intercepted by the SPA fallback. +`/schedules` is reachable from the index and from a session view, so its entry points push `{ back: }` as history state (`backState()` in `web/src/shared/navigation.js`) and the Schedules back button returns there; a direct deep link has no state and falls back to `/`. + ## Sessions Index (`/`) -`SessionsPage.svelte` owns the page shell and orchestrates Svelte components for the sessions list, session cards, command palette, home menu, new-session modal, and project management modal. `web/src/index/` now contains pure data/API helpers (`sessions.js`) for normalization, grouping, filtering, and API calls. +`SessionsPage.svelte` owns the page shell and orchestrates Svelte components for the sessions list, session cards, command palette, home menu, new-session modal, and project management modal. The same `ProjectsModal` is also mounted by the session viewer, opened from the Manage Projects control in its header. `web/src/index/` now contains pure data/API helpers (`sessions.js`) for normalization, grouping, filtering, and API calls. Data comes from existing APIs such as `/api/sessions`, `/api/new-session`, `/api/projects`, `/api/recent-locations`, and `/events?id=__all__`. Running-session status is pushed through the shared SSE helpers and reflected reactively in the cards/counts. diff --git a/e2e/tests/schedules.spec.ts b/e2e/tests/schedules.spec.ts index 12271c7a..9cd88b33 100644 --- a/e2e/tests/schedules.spec.ts +++ b/e2e/tests/schedules.spec.ts @@ -57,6 +57,10 @@ test.describe("schedules (stubbed pi)", () => { await page.locator(".session-header-bar [data-schedules-btn]").click(); await expect(page).toHaveURL(/\/schedules$/); await expect(page.locator(".schedules-page")).toBeVisible(); + + // Back returns to the session it was opened from, not the index. + await page.locator(".session-header-back").click(); + await expect(page).toHaveURL(/\/session\?id=/); }); test("create, run now, view run log, and delete a schedule", async ({ diff --git a/internal/ui/embedded/styles/session.css b/internal/ui/embedded/styles/session.css index a4752645..f13e1b03 100644 --- a/internal/ui/embedded/styles/session.css +++ b/internal/ui/embedded/styles/session.css @@ -2609,7 +2609,8 @@ } .session-header-new, - .session-header-schedules { + .session-header-schedules, + .session-header-projects { height: 24px; padding: 0 9px 0 7px; font-size: 11px; @@ -2641,7 +2642,8 @@ opacity: 0.85; } - .session-header-schedules { + .session-header-schedules, + .session-header-projects { background: color-mix(in srgb, var(--surface) 54%, transparent); color: var(--muted); border: 1px solid color-mix(in srgb, var(--dim) 76%, transparent); @@ -2649,14 +2651,22 @@ transition: color 0.12s, background 0.12s, border-color 0.12s; } - .session-header-schedules-icon { + /* Sits in the left group, which already supplies its own gap. */ + .session-header-projects { + margin-right: 0; + } + + .session-header-schedules-icon, + .session-header-projects-icon { display: inline-flex; align-items: center; color: var(--accent); } .session-header-schedules:hover, - .session-header-schedules:active { + .session-header-schedules:active, + .session-header-projects:hover, + .session-header-projects:active { color: var(--text); background: var(--pi-menu-bg); border-color: var(--pi-menu-border); @@ -2690,7 +2700,8 @@ @media (max-width: 900px) { .session-header-new, - .session-header-schedules { + .session-header-schedules, + .session-header-projects { padding: 0; width: 28px; height: 28px; @@ -2699,7 +2710,8 @@ } .session-header-new-label, - .session-header-schedules-label { + .session-header-schedules-label, + .session-header-projects-label { display: none; } diff --git a/web/src/components/session/CommandMenu.svelte b/web/src/components/session/CommandMenu.svelte index 144824f9..d916a6af 100644 --- a/web/src/components/session/CommandMenu.svelte +++ b/web/src/components/session/CommandMenu.svelte @@ -25,7 +25,7 @@ } from '../../shared/icons.js'; import * as sidebarApi from '../../session/ui/sidebar.js'; import { openVersionModal } from '../../shared/version.js'; - import { navigate, handleNavClick } from '../../shared/navigation.js'; + import { navigate, handleNavClick, backState } 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'; @@ -295,7 +295,11 @@ onclick={(event) => { if (item.external) return; closeMenu(); - handleNavClick(event, item.href); + handleNavClick( + event, + item.href, + item.href === '/schedules' ? { state: backState() } : {}, + ); }} >{@render label(item)}{#if desktop && item.kbd}{item.kbd}{/if} diff --git a/web/src/components/session/CommandMenu.test.js b/web/src/components/session/CommandMenu.test.js index ccfc5a0b..a9c6f3f0 100644 --- a/web/src/components/session/CommandMenu.test.js +++ b/web/src/components/session/CommandMenu.test.js @@ -75,7 +75,7 @@ describe('CommandMenu', () => { expect(link.textContent).toContain('Schedules'); await fireEvent.click(link); - expect(pushState).toHaveBeenCalledWith({}, '', '/schedules'); + expect(pushState).toHaveBeenCalledWith({ back: '/' }, '', '/schedules'); expect(menuBtn.getAttribute('aria-expanded')).toBe('false'); }); diff --git a/web/src/components/session/SessionHeader.svelte b/web/src/components/session/SessionHeader.svelte index 29f84e93..dfb74f19 100644 --- a/web/src/components/session/SessionHeader.svelte +++ b/web/src/components/session/SessionHeader.svelte @@ -3,13 +3,15 @@ import { icon, CalendarClock, + FolderGit2, PanelLeft, Plus, SquarePen, MoreHorizontal, } from '../../shared/icons.js'; import { t } from '../../shared/i18n.js'; - import { navigate, handleNavClick } from '../../shared/navigation.js'; + import { navigate, handleNavClick, backState } from '../../shared/navigation.js'; + import { openProjects } from '../../session/session-modals.svelte.js'; import { showToast } from '../../shared/toast.js'; import { copyToClipboard } from '../../shared/clipboard.js'; import { sessionTitle, setSessionTitle } from '../../session/session-title.svelte.js'; @@ -113,6 +115,17 @@ aria-label={t('session.toggleTree')} aria-pressed="true">{@html icon(PanelLeft, { size: 14 })} + {sessionTitle.name || title}
@@ -122,7 +135,7 @@ data-schedules-btn title={t('schedules.navTitle')} aria-label={t('schedules.navTitle')} - onclick={(event) => handleNavClick(event, '/schedules')} + onclick={(event) => handleNavClick(event, '/schedules', { state: backState() })} >{t('schedules.navTitle')} { vi.restoreAllMocks(); + resetSessionModals(); }); describe('SessionHeader', () => { @@ -25,6 +27,22 @@ describe('SessionHeader', () => { expect(right?.children[1]?.id).toBe('new-session-header-btn'); await user.click(link); - expect(pushState).toHaveBeenCalledWith({}, '', '/schedules'); + expect(pushState).toHaveBeenCalledWith({ back: '/' }, '', '/schedules'); + }); + + it('opens the manage-projects sheet from the header control', async () => { + const user = userEvent.setup(); + render(SessionHeader, { + props: { title: 'How', cwd: '/tmp', sessionId: 's.jsonl', sessionUUID: 'uuid' }, + }); + + const button = document.querySelector('[data-manage-projects-btn]'); + expect(button).toBeTruthy(); + expect(button.textContent).toContain('Manage Projects'); + expect(button.previousElementSibling?.id).toBe('tree-toggle'); + + expect(sessionModals.projects).toBe(false); + await user.click(button); + expect(sessionModals.projects).toBe(true); }); }); diff --git a/web/src/components/session/SessionShell.svelte b/web/src/components/session/SessionShell.svelte index c0187cc7..b2afbea6 100644 --- a/web/src/components/session/SessionShell.svelte +++ b/web/src/components/session/SessionShell.svelte @@ -18,6 +18,9 @@ import LoadEarlier from './LoadEarlier.svelte'; import SessionTree from './SessionTree.svelte'; import ShareDialog from './ShareDialog.svelte'; + import ProjectsModal from '../index/ProjectsModal.svelte'; + import { defaultFetchProjects, defaultUpdateProject } from '../../index/sessions.js'; + import { t } from '../../shared/i18n.js'; import { sessionModals, hasDiffUrlParam, @@ -44,6 +47,60 @@ dataEl = $bindable(null), } = $props(); + // Manage-projects sheet, opened from the header. Enabling/disabling a project + // changes what the sidebar's PROJECTS tab lists, so every successful update + // bumps projectsRevision, which remounts . + let projects = $state([]); + let projectsFilterEnabled = $state(false); + let projectsBusy = $state(false); + let projectsError = $state(''); + let projectsRevision = $state(0); + + async function refreshProjectsList() { + projectsError = ''; + projectsBusy = true; + try { + const response = await defaultFetchProjects(); + projects = Array.isArray(response.projects) ? response.projects : []; + projectsFilterEnabled = !!response.filterEnabled; + } catch (error) { + projectsError = error.message || t('index.failedLoadProjects'); + } finally { + projectsBusy = false; + } + } + + async function updateProject(path, action) { + projectsBusy = true; + projectsError = ''; + try { + await defaultUpdateProject(path, action); + projectsRevision += 1; + await refreshProjectsList(); + } catch (error) { + projectsError = error.message || t('index.failedUpdateProject'); + } finally { + projectsBusy = false; + } + } + + $effect(() => { + if (!sessionModals.projects) return; + document.body.classList.add('modal-sheet-open'); + refreshProjectsList(); + const onKey = (e) => { + if (e.key !== 'Escape') return; + e.preventDefault(); + e.stopPropagation(); + sessionModals.projects = false; + }; + window.addEventListener('keydown', onKey, { capture: true }); + return () => { + document.body.classList.remove('modal-sheet-open'); + window.removeEventListener('keydown', onKey, { capture: true }); + }; + }); + const runtime = getSessionRuntime(); const runningSessionIds = new SvelteSet(); const runningSessionProjects = new SvelteMap(); @@ -154,7 +211,7 @@
- +
@@ -189,6 +246,20 @@ /> + (sessionModals.projects = false)} + onToggleProject={(path, enabled) => updateProject(path, enabled ? 'enable' : 'disable')} + onToggleAll={(enabled) => updateProject('', enabled ? 'enable-all' : 'disable-all')} + onToggleFilter={(enabled) => updateProject('', enabled ? 'enable-filter' : 'disable-filter')} + onRegister={(path) => updateProject(path, 'register')} + onRemove={(path) => updateProject(path, 'remove')} +/> + diff --git a/web/src/components/session/SessionTree.svelte b/web/src/components/session/SessionTree.svelte index c49742a5..643d6ed5 100644 --- a/web/src/components/session/SessionTree.svelte +++ b/web/src/components/session/SessionTree.svelte @@ -13,6 +13,7 @@ sessionId = '', runningSessionIds = null, runningSessionProjects = null, + projectsRevision = 0, } = $props(); const SIDEBAR_TAB_KEY = 'pi-web:v1:left-sidebar-tab'; @@ -108,12 +109,16 @@ hidden={activeTab !== 'projects'} > {#if projectsMounted} - + + {#key projectsRevision} + + {/key} {/if}
diff --git a/web/src/routes/SchedulesPage.svelte b/web/src/routes/SchedulesPage.svelte index 3b328a05..7b8cb757 100644 --- a/web/src/routes/SchedulesPage.svelte +++ b/web/src/routes/SchedulesPage.svelte @@ -327,6 +327,14 @@ function freqLabel(schedule) { return describeFrequency(schedule, t); } + + // /schedules is reachable from the index and from a session view, so the back + // button follows the origin recorded in history state (see backState) and + // falls back to the index for direct deep links. + const backHref = + typeof window !== 'undefined' && typeof window.history.state?.back === 'string' + ? window.history.state.back + : '/'; @@ -334,12 +342,12 @@
{ e.preventDefault(); - navigate('/'); - }}> {t('session.back')} {backHref === '/' ? t('session.back') : t('common.back')}
{t('schedules.title')} diff --git a/web/src/routes/SchedulesPage.test.js b/web/src/routes/SchedulesPage.test.js new file mode 100644 index 00000000..65f1f6bc --- /dev/null +++ b/web/src/routes/SchedulesPage.test.js @@ -0,0 +1,42 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, cleanup, fireEvent } from '@testing-library/svelte'; +import SchedulesPage from './SchedulesPage.svelte'; + +beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })), + ); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function backLink() { + return document.querySelector('.session-header-back'); +} + +describe('SchedulesPage back button', () => { + it('returns to the session that opened it', async () => { + window.history.replaceState({ back: '/session?id=s.jsonl' }, '', '/schedules'); + const pushState = vi.spyOn(window.history, 'pushState'); + render(SchedulesPage); + + const link = backLink(); + expect(link.getAttribute('href')).toBe('/session?id=s.jsonl'); + expect(link.textContent).toContain('Back'); + + await fireEvent.click(link); + expect(pushState).toHaveBeenCalledWith({}, '', '/session?id=s.jsonl'); + }); + + it('falls back to the index when opened directly', async () => { + window.history.replaceState({}, '', '/schedules'); + render(SchedulesPage); + + const link = backLink(); + expect(link.getAttribute('href')).toBe('/'); + expect(link.textContent).toContain('Sessions'); + }); +}); diff --git a/web/src/routes/SessionsPage.svelte b/web/src/routes/SessionsPage.svelte index cd69357a..29a05733 100644 --- a/web/src/routes/SessionsPage.svelte +++ b/web/src/routes/SessionsPage.svelte @@ -18,7 +18,7 @@ hydrateSettings, writeSetting, } from '../shared/settings-store.js'; - import { navigate } from '../shared/navigation.js'; + import { navigate, backState } from '../shared/navigation.js'; import { t } from '../shared/i18n.js'; import { SvelteSet, SvelteMap } from 'svelte/reactivity'; import { @@ -313,7 +313,7 @@ onSearch={openPalette} onToggleMenu={toggleMenu} onLayoutChange={setLayout} - onSchedules={() => navigate('/schedules')} + onSchedules={() => navigate('/schedules', { state: backState() })} /> {} }, label: { open: false, entryId: '', currentLabel: '', onSave: null }, diff: { open: false, sessionId: '' }, + projects: false, }); export function openShortcuts() { @@ -24,6 +25,10 @@ export function openModelUsage() { sessionModals.modelUsage = true; } +export function openProjects() { + sessionModals.projects = true; +} + // Returns false (and does not open) when there are no user messages to fork // from, so the command menu can surface a toast. export function openFork({ entries = [], onSelect = null } = {}) { @@ -96,4 +101,5 @@ export function resetSessionModals() { sessionModals.label.onSave = null; sessionModals.diff.open = false; sessionModals.diff.sessionId = ''; + sessionModals.projects = false; } diff --git a/web/src/shared/navigation.js b/web/src/shared/navigation.js index 39084621..d1a5c534 100644 --- a/web/src/shared/navigation.js +++ b/web/src/shared/navigation.js @@ -9,21 +9,32 @@ function resolveWindow(windowImpl) { return typeof window !== 'undefined' ? window : undefined; } -export function navigate(url, { windowImpl } = {}) { +// `state` is stored on the history entry. Pages that can be reached from more +// than one place (e.g. /schedules) push `{ back: }` so their back +// button returns to the actual origin instead of a hardcoded route. +export function navigate(url, { windowImpl, state } = {}) { const win = resolveWindow(windowImpl); if (!url || !win) return; - win.history.pushState({}, '', url); + win.history.pushState(state || {}, '', url); } // Click handler for elements that point at an internal SPA route. Defers to // the browser's default navigation for modified clicks (open in new tab/window), // non-primary mouse buttons, and already-handled events, so the usual link // affordances keep working; otherwise it intercepts and navigates client-side. -export function handleNavClick(event, url, { windowImpl } = {}) { +export function handleNavClick(event, url, { windowImpl, state } = {}) { if (!url || !event) return; if (event.defaultPrevented) return; if (typeof event.button === 'number' && event.button !== 0) return; if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); - navigate(url, { windowImpl }); + navigate(url, { windowImpl, state }); +} + +// History state for a link into a page (e.g. /schedules) that should offer a +// back button returning to wherever it was opened from. +export function backState({ windowImpl } = {}) { + const win = resolveWindow(windowImpl); + if (!win) return {}; + return { back: win.location.pathname + win.location.search }; }