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
83 changes: 73 additions & 10 deletions core/web/src/components/ConfigModal.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { memo, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { memo, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { Select } from "./Select";
import {
LuBlocks,
LuBrain,
LuCheck,
LuChevronRight,
LuScrollText,
LuExternalLink,
LuEye,
Expand Down Expand Up @@ -153,6 +154,20 @@ function railSnapshot(): { spec: string; name: string }[] {
}
}

/**
* The rail's groups folded by hand in this browser, by their names in English. None is folded until somebody folds
* it: a rail nobody has touched is the one it always was.
*/
const FOLDED_KEY = "pithagoras.settings.rail-closed";
function foldedGroups(): string[] {
try {
const list = JSON.parse(local.get(FOLDED_KEY) ?? "[]");
return Array.isArray(list) ? list.filter((name) => typeof name === "string") : [];
} catch {
return [];
}
}

/**
* An extension's page as Settings' rail lists it: the title and the icon its own page gives (`settings.page`), in
* place of its package's name and the mark every extension has.
Expand Down Expand Up @@ -313,7 +328,7 @@ export const ConfigModal = memo(function ConfigModal({
<SettingsSearch index={index} onPick={go}>
<div className="space-y-4">
{GROUPS.map((g) => (
<RailGroup key={g.label} label={t(g.label)}>
<RailGroup key={g.label} name={g.label} label={t(g.label)} held={nav.kind === "tab" && g.tabs.some((tab) => tab.id === nav.id)}>
{g.tabs.map((tab) => (
<RailItem
key={tab.id}
Expand All @@ -329,7 +344,12 @@ export const ConfigModal = memo(function ConfigModal({

{/* Only appears for extensions that actually read settings. */}
{configurable.length > 0 && (
<RailGroup label={t("Extension settings")} className={exts.value && !railShown.current ? "stagger-in" : ""}>
<RailGroup
name="Extension settings"
label={t("Extension settings")}
held={configurable.some((e) => e.spec === railSpec)}
className={exts.value && !railShown.current ? "stagger-in" : ""}
>
{configurable.map((e) => (
<RailItem
key={e.spec}
Expand Down Expand Up @@ -394,15 +414,58 @@ export const ConfigModal = memo(function ConfigModal({

// --- rail ---

function RailGroup({ label, children, className = "" }: { label?: string; children: ReactNode; className?: string }) {
/**
* One of the rail's groups, under a heading that folds it like a folder. Open or shut as it was left in this
* browser; held open while one of its pages is showing, so that the page marked as the current one is in view, and a
* search that goes to a page in a folded group finds the group open. The search's own list stands in for the rail
* while something is typed, so what it finds is listed whatever is folded.
*/
function RailGroup({
name,
label,
held,
children,
className = "",
}: {
/** What it is kept as: the same in every language. */
name: string;
label: string;
/** One of its pages is showing. */
held: boolean;
children: ReactNode;
className?: string;
}) {
const [shut, setShut] = useState(() => foldedGroups().includes(name));
const open = !shut || held;
const body = useId();
return (
<div>
{label && (
<p className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-fg-faint">
{label}
</p>
)}
<div className={`space-y-0.5 ${className}`}>{children}</div>
<button
type="button"
aria-expanded={open}
aria-controls={body}
aria-disabled={held || undefined}
title={held ? t("Open while one of its pages is showing") : undefined}
onClick={() => {
// A click that shut nothing, with its page in view, would still be remembered as shut.
if (held) return;
// Read again rather than kept from the first draw: the other groups write to the same list.
const others = foldedGroups().filter((n) => n !== name);
local.set(FOLDED_KEY, JSON.stringify(shut ? others : [...others, name]));
setShut(!shut);
}}
// Held open, it does nothing when pressed, and does not look as if it did.
className={`flex w-full items-center gap-1 rounded px-2 pb-1 text-left text-[10px] font-semibold uppercase tracking-wider text-fg-faint transition ${
held ? "cursor-default" : "hover:text-fg-subtle"
}`}
>
{/* A name wider than the rail goes on to a second line rather than being cut off, as the German for "Extension settings" does. */}
<span>{label}</span>
<LuChevronRight aria-hidden className={`ml-auto h-3 w-3 shrink-0 transition-transform motion-reduce:transition-none ${open ? "rotate-90" : ""} ${held ? "opacity-40" : ""}`} />
</button>
<div id={body} role="group" aria-label={label} hidden={!open} className={`space-y-0.5 ${className}`}>
{children}
</div>
</div>
);
}
Expand Down
9 changes: 7 additions & 2 deletions core/web/src/components/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,14 @@ export function Modal({
{rail ? (
<div className="flex min-h-0 flex-1 flex-col sm:flex-row">
{/* Any button in the rail picks a section, so any click on one
turns to it; the rail's own state says which. */}
turns to it; the rail's own state says which. A button that
folds a part of the rail, and says so (aria-expanded), picks
none: the rail stays. */}
<nav
onClick={(e) => (e.target as HTMLElement).closest("button") && setInRail(false)}
onClick={(e) => {
const button = (e.target as HTMLElement).closest("button");
if (button && !button.hasAttribute("aria-expanded")) setInRail(false);
}}
className={`min-h-0 flex-1 overflow-y-auto bg-canvas/40 p-2 sm:block sm:w-52 sm:flex-none sm:shrink-0 sm:border-r sm:border-line xl:w-60 xl:p-3 ${inRail ? "" : "hidden"}`}
>
{rail}
Expand Down
11 changes: 11 additions & 0 deletions docs/guide/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ Plus a page for every extension that exposes configuration, under **Extension
settings**: [Media](/guide/media)'s is **Media**, at
`/settings/x/pithagoras-media`.

Each group folds under its heading: a click, or Enter or Space on it, hides its
pages and shows them again, and the arrow beside the name says which it is. All
of them are open until you fold one, and what you fold stays folded in this
browser, through closing Settings and reloading the page. The group of the page
showing is held open, so the page marked as the current one is always in the
rail: a search, a link or an address that goes to a page in a folded group
finds the group open, and it folds again once you go to a page in another
group. While it is held, its heading does nothing. The search lists what it
finds whatever is folded. On a phone, folding a group keeps you in the list of
pages; it does not open one.

The dialog takes the room the screen has: nearly all of a laptop's, and three
quarters of the width of a wider one. Every page uses that width. Lists
(providers, skills, MCP servers, channels, people, extensions) stand two
Expand Down
152 changes: 152 additions & 0 deletions tests/browser/settings.spec.mts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,158 @@ test('the rail keeps its extension pages from last time, and a provider says it
await expect(dialog.getByTitle('model-a — loaded now')).toBeVisible();
});

/** Where the rail keeps which of its groups were folded, by their English names. */
const FOLDED = 'pithagoras.settings.rail-closed';
const folded = (page: Page) => page.evaluate((key) => localStorage.getItem(key), FOLDED);

test("the rail's groups fold under their headings, with the keys too, and stay as they were left after a reload", async ({ page }) => {
await portal(page);
await page.addInitScript(() => localStorage.setItem('pithagoras.setup', 'done'));
await page.goto('/settings/models');
const dialog = page.getByRole('dialog', { name: 'Settings' });
const reach = dialog.getByRole('button', { name: 'Reach', exact: true });
const extensions = dialog.getByRole('button', { name: 'Extension settings', exact: true });
// Every group is open until somebody folds one, and its heading says which part of the rail it opens.
for (const name of ['Models', 'Agent', 'Reach', 'Portal', 'Extension settings']) {
const heading = dialog.getByRole('button', { name, exact: true });
await expect(heading).toHaveAttribute('aria-expanded', 'true');
await expect(heading).toHaveAttribute('aria-controls', (await dialog.getByRole('group', { name, exact: true }).getAttribute('id'))!);
}
await expect(dialog.getByRole('button', { name: 'pi-web-access' })).toBeVisible();
expect(await folded(page)).toBeNull();

await reach.click();
await expect(reach).toHaveAttribute('aria-expanded', 'false');
await expect(dialog.getByRole('button', { name: 'Channels' })).toBeHidden();
await expect(dialog.getByRole('button', { name: 'People' })).toBeHidden();
await extensions.click();
await expect(dialog.getByRole('button', { name: 'pi-web-access' })).toBeHidden();
expect(JSON.parse((await folded(page))!)).toEqual(['Reach', 'Extension settings']);
// The page showing is not left for the fold.
await expect(dialog.getByText('Online · 12 ms')).toBeVisible();

await page.reload();
await expect(reach).toHaveAttribute('aria-expanded', 'false');
await expect(extensions).toHaveAttribute('aria-expanded', 'false');
await expect(dialog.getByRole('button', { name: 'Channels' })).toBeHidden();
await expect(dialog.getByRole('button', { name: 'pi-web-access' })).toBeHidden();
await expect(dialog.getByRole('button', { name: 'Tools', exact: true })).toBeVisible();

// Enter and Space open and fold it as a click does, and the focus stays on the heading.
await reach.focus();
await page.keyboard.press('Enter');
await expect(reach).toHaveAttribute('aria-expanded', 'true');
await expect(dialog.getByRole('button', { name: 'Channels' })).toBeVisible();
await expect(reach).toBeFocused();
await page.keyboard.press('Space');
await expect(reach).toHaveAttribute('aria-expanded', 'false');
await expect(dialog.getByRole('button', { name: 'Channels' })).toBeHidden();
await page.keyboard.press('Enter');
await expect(reach).toHaveAttribute('aria-expanded', 'true');
expect(JSON.parse((await folded(page))!)).toEqual(['Extension settings']);
await page.reload();
await expect(reach).toHaveAttribute('aria-expanded', 'true');
await expect(extensions).toHaveAttribute('aria-expanded', 'false');
});

test('a page in a folded group, reached by a search or its address, is shown with its group held open, and folded again once it is left', async ({ page }) => {
await portal(page);
await page.addInitScript((key) => {
localStorage.setItem('pithagoras.setup', 'done');
if (!sessionStorage.getItem('seeded')) { sessionStorage.setItem('seeded', '1'); localStorage.setItem(key, JSON.stringify(['Agent', 'Portal', 'Extension settings'])); }
}, FOLDED);
await page.goto('/settings/general');
const dialog = page.getByRole('dialog', { name: 'Settings' });
const portalGroup = dialog.getByRole('button', { name: 'Portal', exact: true });
const extensions = dialog.getByRole('button', { name: 'Extension settings', exact: true });
await expect(dialog.getByLabel('Default model')).toBeVisible();
await expect(portalGroup).toHaveAttribute('aria-expanded', 'false');
await expect(dialog.getByRole('button', { name: 'This browser' })).toBeHidden();
// The group of the page showing is held open: its heading says so, and does nothing.
const models = dialog.getByRole('button', { name: 'Models', exact: true });
await expect(models).toHaveAttribute('aria-expanded', 'true');
await expect(models).toHaveAttribute('aria-disabled', 'true');
await expect(models).toHaveAttribute('title', 'Open while one of its pages is showing');

// What is in a folded group is found all the same, and picking it opens its group with its page marked.
await dialog.getByLabel('Search settings').fill('dunkel');
await expect(dialog.getByRole('option').first()).toContainText('Theme');
await dialog.getByLabel('Search settings').press('Enter');
await expect(dialog.getByRole('radiogroup', { name: 'Theme' })).toBeInViewport();
// What was found stays listed in the rail's place until the search is cleared.
await expect(portalGroup).toBeHidden();
await dialog.getByLabel('Search settings').press('Escape');
await expect(portalGroup).toHaveAttribute('aria-expanded', 'true');
await expect(dialog.getByRole('button', { name: 'This browser' })).toHaveAttribute('aria-current', 'page');
// Held open, a press on its heading neither folds it nor changes what is kept.
await expect(portalGroup).toHaveAttribute('aria-disabled', 'true');
await portalGroup.focus();
await page.keyboard.press('Enter');
await expect(portalGroup).toHaveAttribute('aria-expanded', 'true');
await portalGroup.click({ force: true });
await expect(portalGroup).toHaveAttribute('aria-expanded', 'true');
await expect(models).not.toHaveAttribute('aria-disabled', /.+/);
expect(JSON.parse((await folded(page))!)).toEqual(['Agent', 'Portal', 'Extension settings']);

// An extension's page too; the group left behind is folded again, as it was left.
await dialog.getByLabel('Search settings').fill('safe search');
await dialog.getByRole('option').first().click();
await expect(dialog.getByRole('switch', { name: 'Safe search' })).toBeVisible();
await dialog.getByLabel('Search settings').press('Escape');
await expect(extensions).toHaveAttribute('aria-expanded', 'true');
await expect(dialog.getByRole('button', { name: 'pi-web-access' })).toHaveAttribute('aria-current', 'page');
await expect(portalGroup).toHaveAttribute('aria-expanded', 'false');
await expect(dialog.getByRole('button', { name: 'This browser' })).toBeHidden();

// Opened at a page's address.
await page.goto('/settings/extensions');
const agent = dialog.getByRole('button', { name: 'Agent', exact: true });
await expect(agent).toHaveAttribute('aria-expanded', 'true');
await expect(dialog.getByRole('button', { name: 'Extensions', exact: true })).toHaveAttribute('aria-current', 'page');
await expect(extensions).toHaveAttribute('aria-expanded', 'false');
// Left for a page of another group, the one it was in is folded as it was kept.
await dialog.getByRole('button', { name: 'Providers' }).click();
await expect(agent).toHaveAttribute('aria-expanded', 'false');
await expect(dialog.getByRole('button', { name: 'Extensions', exact: true })).toBeHidden();
});

test("on a phone the rail's groups fold in the rail, without turning to a page, and a page picked in one is shown", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 });
await portal(page);
await page.addInitScript(() => localStorage.setItem('pithagoras.setup', 'done'));
await page.goto('/settings/general');
const dialog = page.getByRole('dialog', { name: 'Settings' });
const portalGroup = dialog.getByRole('button', { name: 'Portal', exact: true });
await expect(dialog.getByLabel('Search settings')).toBeVisible();
await portalGroup.click();
await expect(portalGroup).toHaveAttribute('aria-expanded', 'false');
await expect(dialog.getByRole('button', { name: 'This browser' })).toBeHidden();
// Still the rail: no page in its place, and no way back.
await expect(dialog.getByLabel('Search settings')).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Back', exact: true })).toBeHidden();
await portalGroup.click();
await dialog.getByRole('button', { name: 'This browser' }).click();
await expect(dialog.getByRole('button', { name: 'Back', exact: true })).toBeVisible();
await expect(dialog.getByLabel('Search settings')).toBeHidden();
await expect(dialog.getByRole('radiogroup', { name: 'Theme' })).toBeVisible();
});

test("in German no heading of the rail is cut off, the long one of the extensions' group included", async ({ page }) => {
await portal(page);
await page.addInitScript(() => {
localStorage.setItem('pithagoras.setup', 'done');
localStorage.setItem('pithagoras.language', 'de');
});
await page.goto('/settings/models');
const dialog = page.getByRole('dialog', { name: 'Einstellungen' });
await expect(dialog.getByRole('button', { name: 'pi-web-access' })).toBeVisible();
// None of a name runs past the room it is given, where it would be cut off.
for (const name of ['Modelle', 'Agent', 'Erreichbarkeit', 'Portal', 'Einstellungen der Erweiterungen']) {
const label = dialog.getByRole('button', { name, exact: true }).locator('span');
expect(await label.evaluate((el) => el.scrollWidth <= el.clientWidth), name).toBe(true);
}
});

test('with no model yet, the setup assistant walks through provider, model and packages', async ({ page }) => {
const api = await portal(page, { models: false });
await page.goto('/');
Expand Down
Loading