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
4 changes: 3 additions & 1 deletion docs/architecture/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <origin url> }` 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.

Expand Down
4 changes: 4 additions & 0 deletions e2e/tests/schedules.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ({
Expand Down
24 changes: 18 additions & 6 deletions internal/ui/embedded/styles/session.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -2641,22 +2642,31 @@
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);
text-decoration: none;
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);
Expand Down Expand Up @@ -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;
Expand All @@ -2699,7 +2710,8 @@
}

.session-header-new-label,
.session-header-schedules-label {
.session-header-schedules-label,
.session-header-projects-label {
display: none;
}

Expand Down
8 changes: 6 additions & 2 deletions web/src/components/session/CommandMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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}<kbd>{item.kbd}</kbd>{/if}</a
>
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/session/CommandMenu.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand Down
17 changes: 15 additions & 2 deletions web/src/components/session/SessionHeader.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -113,6 +115,17 @@
aria-label={t('session.toggleTree')}
aria-pressed="true">{@html icon(PanelLeft, { size: 14 })}</button
>
<button
type="button"
class="session-header-projects"
data-manage-projects-btn
title={t('index.manageProjectsTitle')}
aria-label={t('index.manageProjectsTitle')}
onclick={openProjects}
><span class="session-header-projects-icon" aria-hidden="true"
>{@html icon(FolderGit2, { size: 14 })}</span
><span class="session-header-projects-label">{t('index.manageProjects')}</span></button
>
</div>
<span class="session-header-title" id="session-header-title">{sessionTitle.name || title}</span>
<div class="session-header-right">
Expand All @@ -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() })}
><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
Expand Down
20 changes: 19 additions & 1 deletion web/src/components/session/SessionHeader.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ 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';
import { sessionModals, resetSessionModals } from '../../session/session-modals.svelte.js';

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

describe('SessionHeader', () => {
Expand All @@ -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);
});
});
73 changes: 72 additions & 1 deletion web/src/components/session/SessionShell.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 <SessionSidebarProjects>.
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();
Expand Down Expand Up @@ -154,7 +211,7 @@

<div id="sidebar-overlay"></div>
<div id="app">
<SessionTree {cwd} {sessionId} {runningSessionIds} {runningSessionProjects} />
<SessionTree {cwd} {sessionId} {runningSessionIds} {runningSessionProjects} {projectsRevision} />
<div id="content-container" class="content-container">
<main id="content">
<div id="header-container"><SessionInfoHeader model={sessionModel} /></div>
Expand Down Expand Up @@ -189,6 +246,20 @@
/>
<DiffModal bind:open={sessionModals.diff.open} sessionId={sessionModals.diff.sessionId} />

<ProjectsModal
open={sessionModals.projects}
{projects}
filterEnabled={projectsFilterEnabled}
error={projectsError}
busy={projectsBusy}
onClose={() => (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')}
/>

<ShareDialog {sessionId} />
<CatGatekeeper />
<BtwPopup {cwd} parentId={sessionId} />
Expand Down
17 changes: 11 additions & 6 deletions web/src/components/session/SessionTree.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
sessionId = '',
runningSessionIds = null,
runningSessionProjects = null,
projectsRevision = 0,
} = $props();

const SIDEBAR_TAB_KEY = 'pi-web:v1:left-sidebar-tab';
Expand Down Expand Up @@ -108,12 +109,16 @@
hidden={activeTab !== 'projects'}
>
{#if projectsMounted}
<SessionSidebarProjects
{cwd}
currentSessionId={sessionId}
{runningSessionIds}
{runningSessionProjects}
/>
<!-- Remounted whenever the manage-projects sheet changes the registry, so
the list reflects the new enabled set. -->
{#key projectsRevision}
<SessionSidebarProjects
{cwd}
currentSessionId={sessionId}
{runningSessionIds}
{runningSessionProjects}
/>
{/key}
{/if}
</div>

Expand Down
14 changes: 11 additions & 3 deletions web/src/routes/SchedulesPage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -327,19 +327,27 @@
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
: '/';
</script>

<!-- eslint-disable svelte/no-at-html-tags -- trusted: Lucide icon SVG from icons.js -->

<div class="session-header-bar">
<div class="session-header-left">
<a
href="/"
href={backHref}
class="session-header-back"
onclick={(e) => {
e.preventDefault();
navigate('/');
}}><span>←</span> {t('session.back')}</a
navigate(backHref);
}}><span>←</span> {backHref === '/' ? t('session.back') : t('common.back')}</a
>
</div>
<span class="session-header-title">{t('schedules.title')}</span>
Expand Down
Loading