From a65bf899684a83b94a75ba409f65b92d09a3626c Mon Sep 17 00:00:00 2001 From: "pouriade.com" Date: Fri, 23 Oct 2026 11:06:06 -0700 Subject: [PATCH] Put Settings and Admin tabs atop the settings sidebars For admins, the settings and admin sidebars open with Settings and Admin tabs under the way back, in place of the title, rather than a single link at the bottom of the list that was easy to miss. The open area is a raised label, not a link, so only the section's own link marks the current page. --- CHANGELOG.md | 1 + .../src/features/admin/pages/AdminLayout.tsx | 8 +- .../settings/pages/SettingsLayout.tsx | 11 +-- apps/web/src/shared/SectionNav.tsx | 86 ++++++++++++++++--- apps/web/tests/features/admin/admin.test.tsx | 5 +- .../tests/features/settings/settings.test.tsx | 17 +++- specs/web-ui.md | 4 +- 7 files changed, 101 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 548bba8..5cd9eda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ What changed in each release of SCN Chat, for the people who use it, run it, or - The Tags section in the sidebar unfolds and folds as a reply's sources and steps do, rather than jumping open and shut. - The sidebar's chats sit under a Chats heading that folds them away, as the Tags heading does. Both headings are bolder and darker, and the dates under Chats lighter and closer together, so the dates read as a step below them. - The composer's model and thinking effort choices moved out of the card into a quiet line under it, leaving only Attach and Send beside the box. A long model name is cut short there rather than pushing the effort onto a second line, and hovering it shows the whole name. +- For admins, Settings and Admin are now tabs at the top of the settings and admin sidebars, in place of the title, rather than a single link at the bottom of the list. ## [0.5.1] - 2026-10-22 diff --git a/apps/web/src/features/admin/pages/AdminLayout.tsx b/apps/web/src/features/admin/pages/AdminLayout.tsx index 4247e4f..b378fd8 100644 --- a/apps/web/src/features/admin/pages/AdminLayout.tsx +++ b/apps/web/src/features/admin/pages/AdminLayout.tsx @@ -7,12 +7,11 @@ import { LockIcon, PuzzleIcon, RolesIcon, - SettingsIcon, SlidersIcon, SyncIcon, UsersIcon, } from '../../../shared/icons.tsx' -import { NavHeader, SectionNav } from '../../../shared/SectionNav.tsx' +import { AreaTabs, SectionNav } from '../../../shared/SectionNav.tsx' import { SidebarLayout } from '../../../shared/SidebarLayout.tsx' /** The sections in groups: who may use the app, what it runs, and how it runs. */ @@ -44,8 +43,6 @@ const groups = [ }, ] as const -const settings = [{ to: '/settings', label: 'Settings', icon: }] as const - interface Props { children: ReactNode } @@ -56,7 +53,7 @@ export function AdminLayout(props: Props) { kind="settings" nav={ <> - + {groups.map((group) => ( // A plugin's own page counts as the plugins section. ))} - } > diff --git a/apps/web/src/features/settings/pages/SettingsLayout.tsx b/apps/web/src/features/settings/pages/SettingsLayout.tsx index 3e3df98..85698f8 100644 --- a/apps/web/src/features/settings/pages/SettingsLayout.tsx +++ b/apps/web/src/features/settings/pages/SettingsLayout.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react' -import { KeyIcon, PuzzleIcon, ShieldIcon, SlidersIcon, SyncIcon } from '../../../shared/icons.tsx' -import { NavHeader, SectionNav } from '../../../shared/SectionNav.tsx' +import { KeyIcon, PuzzleIcon, SlidersIcon, SyncIcon } from '../../../shared/icons.tsx' +import { AreaTabs, NavHeader, SectionNav } from '../../../shared/SectionNav.tsx' import { SidebarLayout } from '../../../shared/SidebarLayout.tsx' import { version } from '../../../shared/version.ts' import { useMe } from '../../auth/session.ts' @@ -13,13 +13,11 @@ const sections = [ { to: '/settings/sync', label: 'Sync', icon: }, ] as const -const admin = [{ to: '/admin', label: 'Admin', icon: }] as const - interface Props { children: ReactNode } -/** The settings sidebar around the current section, with a way into the admin area for admins. */ +/** The settings sidebar around the current section, with tabs into the admin area for admins. */ export function SettingsLayout(props: Props) { const me = useMe() return ( @@ -27,9 +25,8 @@ export function SettingsLayout(props: Props) { kind="settings" nav={ <> - + {me.admin ? : } - {me.admin && } {/* Which release is running, for checking a deployment or reporting a bug. */}

Version {version}

diff --git a/apps/web/src/shared/SectionNav.tsx b/apps/web/src/shared/SectionNav.tsx index d1a2d58..b437c1a 100644 --- a/apps/web/src/shared/SectionNav.tsx +++ b/apps/web/src/shared/SectionNav.tsx @@ -1,7 +1,7 @@ import { type ActiveOptions, Link, type LinkProps } from '@tanstack/react-router' import type { ReactNode } from 'react' import { cn } from './cn.ts' -import { ArrowLeftIcon } from './icons.tsx' +import { ArrowLeftIcon, SettingsIcon, ShieldIcon } from './icons.tsx' import { sidebarRow } from './ui/sidebar.ts' export interface Section { @@ -18,8 +18,6 @@ interface Props { activeOptions?: ActiveOptions /** A quiet label over the links, for sections in groups. */ label?: string - /** Whether the links lead out of these sections, set apart by a line above them. */ - apart?: boolean } /** @@ -28,7 +26,7 @@ interface Props { */ export function SectionNav(props: Props) { return ( -
+
{props.label && (

{props.label} @@ -65,16 +63,80 @@ interface HeaderProps { export function NavHeader(props: HeaderProps) { return ( <> - - Back to chats - -

+ +

{props.title}

) } + +const areas = [ + { area: 'settings', to: '/settings', label: 'Settings', icon: }, + { area: 'admin', to: '/admin', label: 'Admin', icon: }, +] as const + +/** A tab of `AreaTabs`, its icon a step smaller to suit its text. */ +const areaTab = + 'flex flex-1 items-center justify-center gap-1.5 rounded-row py-1.25 font-medium text-small no-underline [&_svg]:size-3.75' + +interface TabsProps { + /** The area open, which the tabs name in place of a title. */ + open: (typeof areas)[number]['area'] +} + +/** + * For admins, the way back to the chats over tabs between their settings and the admin area, so the + * other area is always in view at the top. The open one is a raised card on the tray rather than a + * link, since it would only lead back to its first section, and marks the area rather than the page, + * which the section's own link marks. + */ +export function AreaTabs(props: TabsProps) { + return ( + <> + +
+ {areas.map((area) => { + if (area.area === props.open) + return ( + + {area.icon} {area.label} + + ) + return ( + + {area.icon} {area.label} + + ) + })} +
+ + ) +} + +/** The way back to the chats, with room below before what names these sections. */ +function BackToChats() { + return ( + + Back to chats + + ) +} diff --git a/apps/web/tests/features/admin/admin.test.tsx b/apps/web/tests/features/admin/admin.test.tsx index ba7231e..3033328 100644 --- a/apps/web/tests/features/admin/admin.test.tsx +++ b/apps/web/tests/features/admin/admin.test.tsx @@ -241,12 +241,15 @@ describe('Admin layout', () => { const nav = screen.getByRole('navigation') expect(within(nav).getByRole('link', { name: 'Back to chats' })).toHaveAttribute('href', '/') expect(within(nav).getByRole('link', { name: 'Settings' })).toHaveAttribute('href', '/settings') + // The open area is a label on the tabs, not a link. + expect(within(nav).queryByRole('link', { name: 'Admin' })).toBeNull() + expect(within(nav).getByText('Admin')).toHaveAttribute('aria-current', 'true') expect(within(nav).getByRole('link', { name: 'Roles' })).toHaveAttribute('href', '/admin/roles') expect(within(nav).getByRole('link', { name: 'Plugins' })).toHaveAttribute( 'aria-current', 'page', ) - expect(nav.querySelectorAll('[aria-current]')).toHaveLength(1) + expect(nav.querySelectorAll('[aria-current=page]')).toHaveLength(1) }) }) diff --git a/apps/web/tests/features/settings/settings.test.tsx b/apps/web/tests/features/settings/settings.test.tsx index 9aedae8..caa4aac 100644 --- a/apps/web/tests/features/settings/settings.test.tsx +++ b/apps/web/tests/features/settings/settings.test.tsx @@ -608,13 +608,24 @@ describe('Settings sections', () => { expect(await screen.findByRole('heading', { name: 'Preferences' })).toBeInTheDocument() }) - it('links to the admin area for admins only', async () => { + it('links to the admin area for admins only, as a tab beside Settings', async () => { stubServer({}) const { unmount } = await renderPage('/sync') - expect(screen.queryByRole('link', { name: 'Admin' })).toBeNull() + // Everyone else sees the area's title, with no tabs. + const nav = screen.getByRole('navigation') + expect(within(nav).queryByRole('link', { name: 'Admin' })).toBeNull() + expect(within(nav).getByText('Settings')).not.toHaveAttribute('aria-current') unmount() await renderPage('/sync', true) - expect(screen.getByRole('link', { name: 'Admin' })).toHaveAttribute('href', '/admin') + const adminNav = screen.getByRole('navigation') + const admin = within(adminNav).getByRole('link', { name: 'Admin' }) + expect(admin).toHaveAttribute('href', '/admin') + expect(admin).not.toHaveAttribute('aria-current') + expect(within(adminNav).getByText('Settings')).toHaveAttribute('aria-current', 'true') + expect(within(adminNav).getByRole('link', { name: 'Sync' })).toHaveAttribute( + 'aria-current', + 'page', + ) }) it("shows the app's version under the sections", async () => { diff --git a/specs/web-ui.md b/specs/web-ui.md index 7908ef3..7ac8409 100644 --- a/specs/web-ui.md +++ b/specs/web-ui.md @@ -93,7 +93,7 @@ The root route's `beforeLoad` asks `/api/me` who is signed in, through the query - **Message actions.** On user messages, edit, which opens the composer prefilled and sends a sibling, copy, and details. Editing another message starts the composer over from that message. On assistant replies, copy, regenerate, which regenerates with the reply's own model, sent with the request so a later default or a sibling's model never takes its place, and More, a menu holding the rest: quote, which adds the text selected inside the reply when the menu opened, or the whole reply when nothing in it was selected, to the composer as a quote chip and focuses it, and says which it will quote; regenerate with, which shows the models in the menu's place and regenerates with the one picked at once; and details. Details, as a button or from More, opens a card of what the message's record says: the model, effort, tokens, status, when it was written in full, its record key, and where it is stored. Regenerate with and details each take the menu's place under a button back to it, and each view starts with focus on its first control. Escape or a click outside closes the menu or card and returns focus to its button. While generating, stop. The actions are icon buttons named by their labels, grouped as the message's actions, and sit at the message's edge, so a reply's start its footer and the user's end theirs, with the most used nearest the edge. A reply's steps button comes after them. The actions show on hover and on keyboard focus, while a menu or card is open, always on the latest reply, since its actions are the ones most often wanted next, and always on a touch screen, which has no hover. The shared view shows details as each message's only action. - **Composer.** A textarea that grows with its text up to a cap, then scrolls, whose placeholder instructs rather than asks: "Ask anything" in a new chat, and "Reply" under a message, and nothing while an open chat's messages are on their way, so a chat opened on a reload never says one and then the other, and a chat just started goes from "Ask anything" to nothing to "Reply" as it opens, and a row under it with an attach button and Send, and under the card a model select from `/api/models`. Attach and Send are icon buttons at either end of the row, a paperclip that opens the hidden file input and an up arrow, so the main action always sits in the corner. The model select is quiet caption text in a line under the card, starting in line with the textarea's text. The line never wraps, so a long model name is cut short with an ellipsis, with the whole name in the button's tooltip and in its list where the list has room. Once its list holds eight entries or more, about as many as it shows before scrolling, it opens with a Search models box, which finds a model by its name, its ID, or its provider, as the model select in Preferences does. For a model with the reasoning capability, the chosen one or, with none chosen, the model the server falls back to, a quiet effort select, the same one as in Preferences, sits after it on that line, keeping its width, and shows the chosen level's pie and name. Each message names the model that answers it, the one chosen or, with none chosen, the one the select names, so a chat started on the default keeps that model whatever later becomes of the default. In a chat with a model of its own, the model last used on the branch, the select shows that model by name and offers no "Default", following a switch of model from the moment the message that made it arrives, through a reply that is running, stopped, or failed, and on reopening the chat. A chosen model the catalog no longer offers shows as unavailable, and is never swapped for another. Before the chat has a model, "Default" is the default from settings, the user's or the admin's, and names it, as "Default · Claude Sonnet", once the catalog offers it, and says "Default model" otherwise. While an open chat's messages are on their way, the select shows a loading line and no effort, since nothing yet says which model the chat uses. The theme grows the textarea with `field-sizing: content`, and where a browser lacks it, `useAutoGrow` sets its height from its text. The effort is sent only while its select is shown. It starts on the effort last chosen in the composer on this device, kept in `localStorage` as a convenience, or on Auto. The attach button accepts images only for models with vision, plus any type an ingester supports. With no model chosen it follows the fallback model's capabilities when the catalog lists it, and otherwise accepts images and leaves the check to the server. Files also attach when pasted into the textarea or dropped anywhere on the conversation, which marks itself with `data-dropping` while files are dragged over it. A paste that carries text as well as files pastes as text, since office apps copy a picture of the copied text alongside it. Pasted and dropped files skip the attach button's filter, so the server refuses a type no ingester reads, and an image for a model without vision is refused before upload. Each file uploads as soon as it is added and shows as a chip above the textarea, with a local preview for an image or a file icon, its name over its kind and size, as "PDF · 1.2 MB", with "Uploading…" in their place until its upload finishes, and a button that removes it. Removing a file while it uploads cancels the upload. A failed upload drops its chip and says which file failed and why. An image attached before switching to a model without vision stays, and Send is disabled under a note that the model can't read images, until the image is removed or a model that can is chosen. Send waits until every upload has finished, and sending lets go of the attachments it sent, keeping any added while it was on its way. Quotes from a reply show above the attachments, each as a block quote across the card cut to two lines, the whole text on hover, and a button that removes it, so the user sees what the model will be shown apart from what they wrote. Sending puts each quote at the start of the message as a Markdown block quote, in the order they were added, above the text, and lets go of the quotes it sent, keeping any added while it was on its way. Send is disabled while there is nothing to send, no text but spaces, no quotes, and no attachments. Once the composer holds any text, quote, or attachment, a Clear button, an × in the top corner of the box, as a field's own clear button rather than among the controls under it, empties the textarea, removes every quote and attachment, cancelling uploads still running, clears the composer's error, and focuses the textarea. It is disabled while a message is on its way, since sending clears the composer itself. Each paragraph in the textarea runs in the direction of its own text, as Persian runs right to left, while the box keeps the page's direction, so text either way stops short of Clear. Enter sends, and Shift+Enter adds a newline. On a touch screen, which has no hover, Enter adds a newline too and Send sends, as phone keyboards lead people to expect, and an Enter that an input method uses to pick its words, as for Japanese, never sends. Send is disabled while a message is on its way, and Enter does nothing then, so a message is sent once. Until an open chat's messages arrive, Send is disabled and Enter does nothing too, since the message would not yet know which one it answers. While the reply at the end of the branch runs, Stop, a square icon, takes Send's place and Enter does nothing, and that reply's own actions leave Stop to the composer. A composer editing an earlier message says so above the box, that sending adds the message as a new version, with a Cancel button, and Escape in the textarea cancels too. Its Send's tooltip says "Send as a new version", and it sends as usual. The file input clears after each pick, so choosing the same file again attaches it again. The model select offers "Default model" only when the server can choose a model without one, following chat-turns: the user's preferences or the admin set a default. Otherwise the composer asks for a model, and sending waits until one is chosen. When `/api/models` lists no models at all, Send is disabled and a note points to Settings to add an API key. - While the page's code loads, the composer's frame stands in for it, in the same card at the same place: a plain textarea with the composer's placeholder, which is "Ask anything" for a new chat and nothing for an open one, whose messages haven't said yet whether it replies, the attach button and Send, and the model select's loading line under the card, all disabled but the textarea. The server sends it with the page's frame, so the box takes typing before any script runs, and Enter only adds a line, since there is nothing yet to send it. The conversation's column shows it again while its own code downloads. Each frame that gives way leaves what its box holds, with the cursor and whether it had the focus, in `composer-handoff.ts`, and the frame or composer that takes its place shows the text, puts the cursor back, and takes the focus, so the swap can't be seen. A frame with no text that never had the focus leaves nothing. Only a composer that starts empty takes a draft, never one editing a message, and a draft nothing took, as when the page fails to load, waits for the next one. The frame and the composer are built from the same parts, in `composer-parts.tsx`. The composer's own parts, in `Composer.tsx`, read its draft from a `Composer.Provider`, and `ReplyComposer` and `EditComposer` each compose the parts they show. -- **Settings.** A sidebar links back to the chats and to each section, with an icon, marks the current one, and links to the admin area for admins. Under the links, a quiet line names the app's version, from the root `package.json`, so anyone can tell which release a deployment runs. Signing out is in the chat sidebar's account menu. Only the current section is shown, under its title and a line saying what it is for, as cards of related settings in one narrow column. A setting is a row with its label and a description on the left and its control on the right, which drops under them on a narrow screen. On/off settings are checkboxes drawn as switches, except several on one row, such as what a key's model can do, which are toggle chips. A form's Save sits in a bar that sticks to the foot of the panel, shows "Saved" after a save until the next change, and shows a failed save's message. Save is disabled until the form differs from what was saved, and the bar says "Unsaved changes" beside it until then, though not beside a button that adds, such as Add plugin. Leaving the page doesn't ask, so unsaved changes are lost. Below 720px the sidebar is a drawer, as on the chat page, opened by a toggle at the top of the page and closed by the backdrop or by following a link in it. The admin area shares this layout and these pieces, which live in `src/shared/ui/SettingsPage.tsx`. +- **Settings.** A sidebar links back to the chats and to each section, with an icon, and marks the current one. Under the way back, it names the area: "Settings" as a title, or, for admins, two tabs, Settings and Admin, with icons, so the way into the other area is always at the top. The open one is raised on a grey tray and is not a link, since it would only lead back to its first section, and it marks the open area while the section's link marks the page. Under the links, a quiet line names the app's version, from the root `package.json`, so anyone can tell which release a deployment runs. Signing out is in the chat sidebar's account menu. Only the current section is shown, under its title and a line saying what it is for, as cards of related settings in one narrow column. A setting is a row with its label and a description on the left and its control on the right, which drops under them on a narrow screen. On/off settings are checkboxes drawn as switches, except several on one row, such as what a key's model can do, which are toggle chips. A form's Save sits in a bar that sticks to the foot of the panel, shows "Saved" after a save until the next change, and shows a failed save's message. Save is disabled until the form differs from what was saved, and the bar says "Unsaved changes" beside it until then, though not beside a button that adds, such as Add plugin. Leaving the page doesn't ask, so unsaved changes are lost. Below 720px the sidebar is a drawer, as on the chat page, opened by a toggle at the top of the page and closed by the backdrop or by following a link in it. The admin area shares this layout and these pieces, which live in `src/shared/ui/SettingsPage.tsx`. - Preferences: a theme of System, Light, or Dark under Appearance, which applies at once and is kept on this device in `localStorage` rather than in the preferences record, as the styling spec says. The account menu offers the same choice. Then default model and thinking effort under Models, custom instructions, and generate titles under Chats. Saving also stores the browser's time zone, which the app saves on sign-in as well when it differs from the stored one. A stored default model the model list no longer offers, as after its key is deleted, shows as unavailable and is kept on save until the user picks another. When the preferences can't load, the section shows why and no form. - API keys: a list of the user's keys, each with its name, provider, how many models it covers, and last four characters, or a note when there are none. Each row opens the key's page. Deleting a key asks to confirm in its row first. Below it, a form to add a key, with its labels over its fields: provider, key, an optional name, and model IDs, with a button to load the provider's models where it lists them. Loaded models appear below the field as a checklist, none ticked, and the key is added with the ticked ones and any typed IDs. For providers with user endpoints, base URL and slug fields. The slug fills in from the name as it is typed, until the user edits the slug, and clearing it hands it back to the name. A key's page (`/settings/api-keys/`), under a link back to the list, is titled with the key's name over its provider and base URL. Its Models card has a row for each model: its name, its ID when that differs, Vision, Thinking, and Tools as chips that stay pressed while on, and a button that removes it. In a narrow column the chips go under the name. Under it, Add models has a field to type a model ID, where Enter adds rather than saves, and, for providers that list their models, Load models, which lists them with the saved key to tick the ones to use. Unticking one takes it out of the draft, as its remove button does. A key that can't be read anymore shows a red note asking for it to be deleted and added again, and a page for a key that isn't saved anymore says so in place of the form. The changes are one draft under one Save, which is disabled while the key has no models. Below the draft, a red-edged card deletes the key, asking to confirm in place first, then returns to the list. The API keys section stays marked in the sidebar there. - Plugins: one form with a card per plugin and a single Save, which saves every plugin's settings and any tool switches that changed. A card has a switch for each tool the user may switch, labeled "Enabled" when it is the plugin's only one and by tool name otherwise, with the tool's description, then the fields generated from the plugin's JSON schema. When no plugin has settings or switches for the user, the section says so rather than showing nothing. @@ -107,7 +107,7 @@ The root route's `beforeLoad` asks `/api/me` who is signed in, through the query - The sharing panel, the shared page, which the server also sends as its skeleton, settings and admin pages, the users list, and the Sync page's background switch each show their own shapes. A button busy with what it was asked, such as Save or Load models, keeps its label with a spinner before it. Sign in's Continue is the exception: it reads "Signing in…" and drops its arrow, since it is leaving the page. -- **Admin.** The admin specs describe each section. `features/admin/pages/` mirrors `features/settings/pages/`: a layout for the sidebar, which groups the sections under People, Features, and App, each with an icon, links back to the chats and to the account settings, marks the current section, a plugin's page counting as Plugins, and one file per section. Sections are built like the settings ones, with tables and lists in cards, in the same column width as every other section. A page opened from another, such as a role's or a plugin's, has a link back above its title. Removing a role, a role's member, a plugin, or a model, and revoking a key, asks to confirm in place first. Suspending a user first asks for a reason, seen only by admins. Status, such as a plugin's or whether an account is suspended, shows as a badge. A form that edits stored values starts from them, and starts over when they change after a save. A failed save shows its message, and the field issues the server returns show beside their fields. +- **Admin.** The admin specs describe each section. `features/admin/pages/` mirrors `features/settings/pages/`: a layout for the sidebar, which groups the sections under People, Features, and App, each with an icon, links back to the chats, holds under the way back the same Settings and Admin tabs as the settings sidebar, marks the current section, a plugin's page counting as Plugins, and one file per section. Sections are built like the settings ones, with tables and lists in cards, in the same column width as every other section. A page opened from another, such as a role's or a plugin's, has a link back above its title. Removing a role, a role's member, a plugin, or a model, and revoking a key, asks to confirm in place first. Suspending a user first asks for a reason, seen only by admins. Status, such as a plugin's or whether an account is suspended, shows as a badge. A form that edits stored values starts from them, and starts over when they change after a save. A failed save shows its message, and the field issues the server returns show beside their fields. ### Schema forms -- knotmirror 0.0.1