From 6edccbbe2e94cf56757aebacfc927730ff86be5e Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sun, 11 Oct 2026 23:20:59 +0000 Subject: [PATCH 1/3] Workbench: routing under the generator when a narrow panel is tall enough, and the grouped profile by provider then level Below 760 px the stage now weighs the panel's height as well: when the generator without its grouped profile and the whole routing pane fit the visible height together, routing stands under the generator and usage under routing, with no Routing or Usage tabs, and p and s hide and show them as at wider widths. Otherwise the narrow layout stays as it was. The fit is measured from what is rendered, on resize and on structural changes only, never on a readout or launch-line change, with the same quantities in both arrangements and a small grace so it never oscillates. The grouped profile orders its groups by provider (the default role's first, then the panel's provider order) and within a provider by catalog tier, then thinking, then alias; roles keep routing order. Refs #239 --- README.md | 4 +- docs/configuration.md | 42 +++--- plugins/code/generator/generator-pane.tsx | 12 +- plugins/code/generator/panel-keys.ts | 26 ++-- plugins/code/generator/routing-model.ts | 30 ++-- plugins/code/generator/routing-pane.tsx | 13 +- plugins/code/generator/styles.css | 15 +- plugins/code/generator/web.tsx | 167 +++++++++++++++++----- plugins/scripts/verify-browser.ts | 26 +++- plugins/test/panel-keys.test.ts | 10 +- plugins/test/routing.test.ts | 22 ++- 11 files changed, 266 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index 578191f..4401023 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,9 @@ quota preservation and Code-owned upstream fallback repair are not planned. stays focusable and the launch line names what failed (the accounts, the workspace profile, OMP missing from the machine) with a fix. **Routing** beside it lists every role's `model:thinking`, with a fallback-chains checkbox, and **usage** under both; - each can be hidden. Below 760 px routing and usage are tabs of their own. The + each can be hidden. Below 760 px routing and usage stand under the generator when the + panel is tall enough for the generator and the whole routing at once, and are tabs of + their own otherwise, the profile then grouped under the rows by provider and level. The **sessions** list running and explicitly read saved sessions and this device's recent profiles. Priority, prewalk and auto plans are profile switches in Options. Every action is a visible control; keys are accelerators, listed under diff --git a/docs/configuration.md b/docs/configuration.md index 390f893..c5eac99 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -244,8 +244,9 @@ Models names `m` only in the generator, where `m` opens Models rather than Manag ### The top bar The tabs are a tab list: `←` `→` move among them and `↵` or `Space` opens one. Below -760 px of panel width **Routing** and **Usage** join them, since those panes are views -of their own there. **More** keeps to the bar's end and is a standard menu button: a +760 px of panel width, when the panel is too short for routing under the generator, +**Routing** and **Usage** join them as views of their own (see the layout below). +**More** keeps to the bar's end and is a standard menu button: a press, `↓`, `↵` or `Space` opens its menu on the first item and `↑` on the last; `↑` `↓`, `Home` and `End` move, `↵` or `Space` chooses, and an item's own key (`m`, `u`, `o`, `?`) chooses it too. `Esc` closes the menu and returns focus to More; `Tab`, a press outside @@ -365,10 +366,12 @@ fallbacks row and says there are no chains to show. Its state is local to the op panel. **Hide** in routing's head (`p`) removes the pane; **Show routing** in the generator's head brings it back. -Whenever routing is not beside the rows (a narrow panel, or hidden) the team shows -under the rows grouped by what it runs: each group's `model:thinking`, then its roles -with `●` on agent-backed ones. A change that moves a role between groups glides it -there. +Whenever routing is not shown with the generator (hidden, or a tab of its own in a short +narrow panel) the profile shows under the rows grouped by what it runs: each group's +`model:thinking`, then its roles in routing order with `●` on agent-backed ones. Groups +read by provider, the default role's first and then GPT, Claude, DeepSeek and the rest, +and within a provider by level: the higher tier first (elite, smart, normal, fast), then +the higher thinking. A change that moves a role between groups glides it there. The **cost** and **speed** meters sit above the launch: five glyphs each (`$` and `»`), lit to the level in the lane's accent, and the level's word while pointed. Cost is a @@ -507,7 +510,8 @@ never a refusal. ### Usage The **usage** pane stands under routing, or alone as the **Usage** tab in a narrow -panel. It lists providers in the panel's family order (GPT, Claude, DeepSeek, then the +panel too short for routing under the generator. It lists providers in the panel's +family order (GPT, Claude, DeepSeek, then the rest) and, under each, its accounts by email (else API key or identity). Each reported window of an account is one line: its label (`5h`, `7d`), a twelve-block bar filled to the share used, the percentage, `↻` and the time to its reset, and Code's word for it: @@ -690,9 +694,9 @@ generator's rows. Each of these has a control; none is the only way to anything. - **Accounts.** `↑` `↓`, `Home`, `End`, `Space`, `↵`, `m` and `a` as above. - **Manage.** Esc returns to the accounts, `a` to the generator. - **Sessions.** `↑` `↓` move, `1`–`9` recall a recent profile, `e` or Esc go back. -- **Narrow routing and usage views.** Esc goes back and `a` opens the accounts; `p` and - `s` open routing and usage, and return to the main view from their own; `f` shows the - chains in routing. +- **Routing and usage tabs.** Where they are tabs of their own, `p` and `s` open routing + and usage rather than hide them, and return to the main view from their own; there Esc + goes back, `a` opens the accounts and `f` shows the chains in routing. - **Anywhere in the panel.** `r` is Refresh now: everything read again at once. `?` opens Shortcuts. @@ -707,7 +711,7 @@ which has no keys, the key line is hidden, and every button, tab and radio is at 44 px. One polite live region announces outcomes, failures, the charge, recalls and the reason a pressed refused control gave. -The layout answers the panel's own measured width, never the window's, since Code +The layout answers the panel's own measured size, never the window's, since Code shares the window with its canvas. From 760 px the generator and routing stand side by side with usage under both. The generator is as wide as its rows need, never under 42% of the panel, and routing takes what that leaves, never under 36%, so the room the rows @@ -715,13 +719,19 @@ do not need goes to routing's fallback chains. What the readout and the launch l never moves that divide. The generator and routing are as tall as the taller of them, and usage follows them, so the room a tall panel has left lies below everything rather than inside the generator. -Below 760 px the generator stands alone with the grouped team under its rows, and routing -and usage are tabs of their own; widening the panel closes such a view. Below 430 px the lead's words drop under its label so the +Below 760 px routing stands under the generator and usage under routing when the panel is +tall enough for the generator, without its grouped profile, and the whole routing at once; +otherwise the generator stands alone with the grouped profile under its rows, and routing +and usage are tabs of their own. The fit is judged from what is rendered, when the panel is +resized and when the stage's structure changes (the routes, the fallback chains, routing +hidden or shown, a return to the generator), never when the readout or the launch line +changes: a later overflow scrolls, and the launch never moves under the pointer. Widening +the panel closes a routing or usage view. Below 430 px the lead's words drop under its label so the leads and the only box keep one line, and below 380 px usage windows use thinner blocks and drop the word "used". Nothing scrolls sideways at any width. Hiding routing or usage is -local to the open panel and not kept. The first layout and every resize place things -without motion; a pane that comes into view slides in, and under reduced motion nothing -moves. +local to the open panel and not kept. The first layout, every resize and every change +between the two narrow arrangements place things without motion; a pane that comes into +view slides in, and under reduced motion nothing moves. ### Drafts and what persists diff --git a/plugins/code/generator/generator-pane.tsx b/plugins/code/generator/generator-pane.tsx index f59b495..1195a72 100644 --- a/plugins/code/generator/generator-pane.tsx +++ b/plugins/code/generator/generator-pane.tsx @@ -89,11 +89,11 @@ export type GeneratorPaneProps = { readonly recents: readonly RecentTeam[]; /** Families with a signed-in account, included or not; null when the reading cannot say. */ readonly connected: ReadonlySet | null; - /** The routing's rows, which the narrow profile groups. */ + /** The routing's rows, which the grouped profile groups. */ readonly ledger: readonly LedgerRow[] | null; /** The review the routing shows: the reviewed composition while current, else the local review. */ readonly shown: Review | null; - /** Routing is not beside the generator, so the team shows grouped under its rows. */ + /** Routing is not shown with the generator, so the profile shows grouped under its rows. */ readonly profile: boolean; /** Another view has the stage; the pane keeps its state behind it. */ readonly hidden: boolean; @@ -123,10 +123,10 @@ type Scrub = { readonly row: RowId; readonly origin: Review | null; readonly ref /** * GENERATOR: one row of plain option words per dial and extra, a readout line that says - * what is pointed, the narrow profile when routing is not beside, the cost and speed meters and the - * launch line. Every action goes through the workbench model's gate; the pane only names the step, - * shows what each word would do and quota's verdict on it, and says the consequence of whatever is - * pointed, pressed or scrubbed in the readout. + * what is pointed, the grouped profile when routing is not shown with it, the cost and speed meters + * and the launch line. Every action goes through the workbench model's gate; the pane only names the + * step, shows what each word would do and quota's verdict on it, and says the consequence of whatever + * is pointed, pressed or scrubbed in the readout. */ export function GeneratorPane(props: GeneratorPaneProps) { const { model, pools, machines, presence, selectMachine, recents, connected, ledger, shown, profile, hidden, listFailure, reveal, announce, onOpen, onRefresh, onLaunchState, onExtras, controls } = props; diff --git a/plugins/code/generator/panel-keys.ts b/plugins/code/generator/panel-keys.ts index b43a2a8..fc6a301 100644 --- a/plugins/code/generator/panel-keys.ts +++ b/plugins/code/generator/panel-keys.ts @@ -7,7 +7,7 @@ * the last word. A generator row's own keys (←→, ↑↓, Home, End, Space) are the row's (dial-row.tsx). */ -/** What the stage shows: the generator with routing and usage, a pane alone (narrow), the accounts, their management (`manage`, under the accounts) or the sessions. */ +/** What the stage shows: the generator with routing and usage, a pane alone (its tab), the accounts, their management (`manage`, under the accounts) or the sessions. */ export type PanelView = "main" | "routing" | "usage" | "accounts" | "manage" | "sessions"; /** The sheets that open over the stage. */ export type PanelSheet = "models" | "setup" | "options"; @@ -31,8 +31,8 @@ export type PanelKey = { /** Nothing in the stage has focus: the panel root, which has focus when the panel opens. */ readonly onRoot: boolean; readonly view: PanelView; - /** The panel is too narrow for routing and usage beside the generator; they open as views of their own. */ - readonly narrow: boolean; + /** Routing and usage are tabs of their own, as when the panel has room for them neither beside nor under the generator. */ + readonly tabbed: boolean; /** How many recent profiles the digits can recall, in the sessions view. */ readonly recents: number; }; @@ -42,7 +42,7 @@ export type PanelAction = | { readonly kind: "defaults" } | { readonly kind: "saved" } | { readonly kind: "chains" } - /** Hide or show a pane beside the generator. */ + /** Hide or show a pane shown with the generator. */ | { readonly kind: "toggle"; readonly pane: "routing" | "usage" } /** Show a view; `main` goes back. */ | { readonly kind: "view"; readonly view: PanelView } @@ -63,11 +63,11 @@ const SHEET_KEYS: Readonly> = { u: "setup", o: "optio * What a key press means, or null when the panel leaves it alone. Mod+↵ takes the launch's step in * the main view; a plain ↵ does too unless a control has focus, which answers it itself. Neither * acts on key repeat, so holding them cannot take one step after another. Letters act outside text: - * `a` and `e` open the accounts and the sessions or come back; `p` and `s` hide routing and usage - * beside the generator, or open them as views when narrow; `d`, `z`, `f` and `w` (the machine) act - * in the main view; `m`, `u` and `o` open Models, Setup and the session options from the main view, - * and `m` the accounts' management from the accounts; `r` reads again and `?` shows every key. - * Digits recall a recent profile where the sessions view lists them. Esc goes back: from the + * `a` and `e` open the accounts and the sessions or come back; `p` and `s` hide or show routing and + * usage with the generator, or open their tabs when they have them; `d`, `z`, `f` and `w` (the + * machine) act in the main view; `m`, `u` and `o` open Models, Setup and the session options from the + * main view, and `m` the accounts' management from the accounts; `r` reads again and `?` shows every + * key. Digits recall a recent profile where the sessions view lists them. Esc goes back: from the * accounts' management to the accounts, from anything else to the main view; at rest in the main * view it is left alone. Every one of these is also a control on the panel; keys only speed them up. */ @@ -85,8 +85,8 @@ export function panelKey(press: PanelKey): PanelAction | null { switch (press.key) { case "a": return { kind: "view", view: press.view === "accounts" || press.view === "manage" ? "main" : "accounts" }; case "e": return { kind: "view", view: press.view === "sessions" ? "main" : "sessions" }; - case "p": return press.narrow ? { kind: "view", view: press.view === "routing" ? "main" : "routing" } : main ? { kind: "toggle", pane: "routing" } : null; - case "s": return press.narrow ? { kind: "view", view: press.view === "usage" ? "main" : "usage" } : main ? { kind: "toggle", pane: "usage" } : null; + case "p": return press.tabbed ? { kind: "view", view: press.view === "routing" ? "main" : "routing" } : main ? { kind: "toggle", pane: "routing" } : null; + case "s": return press.tabbed ? { kind: "view", view: press.view === "usage" ? "main" : "usage" } : main ? { kind: "toggle", pane: "usage" } : null; case "d": return main ? { kind: "defaults" } : null; case "z": return main ? { kind: "saved" } : null; case "f": return main || press.view === "routing" ? { kind: "chains" } : null; @@ -109,10 +109,10 @@ export function panelKey(press: PanelKey): PanelAction | null { * null where none does. A control names it as its accelerator, so it never claims a key that does * something else in that view, or nothing. */ -export function acceleratorFor(view: PanelView, narrow: boolean, keys: readonly string[], does: (action: PanelAction) => boolean): string | null { +export function acceleratorFor(view: PanelView, tabbed: boolean, keys: readonly string[], does: (action: PanelAction) => boolean): string | null { return keys.find(key => { const action = panelKey({ key, mod: false, alt: false, repeat: false, defaultPrevented: false, inView: true, inField: false, - inDialog: false, onControl: false, onRoot: false, view, narrow, recents: 0 }); + inDialog: false, onControl: false, onRoot: false, view, tabbed, recents: 0 }); return action !== null && does(action); }) ?? null; } diff --git a/plugins/code/generator/routing-model.ts b/plugins/code/generator/routing-model.ts index 3972d68..08d2d28 100644 --- a/plugins/code/generator/routing-model.ts +++ b/plugins/code/generator/routing-model.ts @@ -1,14 +1,16 @@ +import { ThinkingLevelSchema } from "@atyrode/manifold-omp"; import type { CompiledCatalog } from "../../domain/catalog.ts"; -import type { ModelChoice } from "../../domain/contracts.ts"; +import type { CatalogModel, ModelChoice } from "../../domain/contracts.ts"; +import { orderedFamilies } from "../../domain/providers.ts"; import { roleOutcomes, type QuotaPool } from "../../domain/quota.ts"; import type { Review } from "../../domain/routing.ts"; import { outWord, poolOf, thinkingWord, type Vocabulary } from "./statement-model.ts"; /* * The routing as data: every role on one line with its lead as `alias:thinking` in its provider's - * hue, the fallback chain behind it, and whether quota leaves the lead out; and the same team - * grouped by what it runs, for the narrow profile. Routes come only from the review and quota only - * from `quotaPools`/`roleOutcomes`; nothing here judges either. + * hue, the fallback chain behind it, and whether quota leaves the lead out; and the same profile + * grouped by what it runs, wherever routing is not shown with the generator. Routes come only from + * the review and quota only from `quotaPools`/`roleOutcomes`; nothing here judges either. */ /** One model choice as the routing writes it. `signature` changes exactly when the token reads or colours differently. */ @@ -21,6 +23,8 @@ export type RouteToken = { readonly id: string; /** The thinking level as the readout writes it (`x-high`). */ readonly effort: string; + /** The model's catalog tier (4 elite down to 1 fast; 0 a special tier), which orders the grouped profile. */ + readonly tier: CatalogModel["tier"]; }; export type LedgerRow = { readonly role: string; @@ -37,7 +41,7 @@ function token(catalog: CompiledCatalog, aliases: ReadonlyMap, c const model = catalog.model(choice.key); return { key: choice.key, alias: aliases.get(choice.key) ?? choice.key, thinking: choice.thinking, family: catalog.family(choice.key), - id: `${model.provider}/${model.id}`, effort: thinkingWord(choice.thinking), + id: `${model.provider}/${model.id}`, effort: thinkingWord(choice.thinking), tier: model.tier, }; } @@ -66,10 +70,15 @@ export function routeLedger(catalog: CompiledCatalog, review: Review, aliases: R return rows; } -/** One group of the narrow profile: a lead and thinking, and the roles that run it in routing order. */ +/** One group of the grouped profile: a lead and thinking, and the roles that run it in routing order. */ export type ProfileGroup = { readonly key: string; readonly lead: RouteToken; readonly down: boolean; readonly roles: readonly { readonly role: string; readonly agentBacked: boolean }[] }; -/** The team grouped by what it runs (`model:thinking`), groups in the order their first role appears; an off advisor runs nothing. */ +/** + * The profile grouped by what it runs (`model:thinking`), read by provider and then by level: the + * default role's provider first and the others in the panel's provider order, then within a provider + * the stronger tier first, then the higher thinking, then the alias, so equal groups always stand in + * one order. Roles keep their routing order inside a group; an off advisor runs nothing. + */ export function profileGroups(ledger: readonly LedgerRow[]): ProfileGroup[] { const groups = new Map(); for (const row of ledger) { @@ -78,5 +87,10 @@ export function profileGroups(ledger: readonly LedgerRow[]): ProfileGroup[] { const group = groups.get(key) ?? groups.set(key, { key, lead: row.lead, down: row.down !== null, roles: [] }).get(key)!; group.roles.push({ role: row.role, agentBacked: row.agentBacked }); } - return [...groups.values()]; + const first = ledger.find(row => row.role === "default")?.lead?.family; + const families = orderedFamilies([...groups.values()].map(group => group.lead.family)); + const rank = first === undefined ? families : [first, ...families.filter(family => family !== first)]; + const scale: readonly string[] = ThinkingLevelSchema.options; + return [...groups.values()].sort((a, b) => rank.indexOf(a.lead.family) - rank.indexOf(b.lead.family) || b.lead.tier - a.lead.tier + || scale.indexOf(b.lead.thinking) - scale.indexOf(a.lead.thinking) || a.lead.alias.localeCompare(b.lead.alias)); } diff --git a/plugins/code/generator/routing-pane.tsx b/plugins/code/generator/routing-pane.tsx index 11c13c4..81c3c46 100644 --- a/plugins/code/generator/routing-pane.tsx +++ b/plugins/code/generator/routing-pane.tsx @@ -101,7 +101,7 @@ export type RoutingPaneProps = { /** The profile's fallbacks are on, so there are chains to show. */ readonly fallbacks: boolean; readonly onChains: () => void; - /** The head's Hide control beside the generator; none when routing is a view of its own. */ + /** The head's Hide control while routing shows with the generator; none when it is a tab of its own. */ readonly hide: ReactNode; readonly hidden: boolean; }; @@ -137,10 +137,11 @@ export function RoutingPane({ ledger, chains, fallbacks, onChains, hide, hidden } /** - * The narrow profile, under the generator's rows whenever routing is not beside them: the team - * grouped by what it runs, each group's `model:thinking` then its roles, ● for the agent-backed. When - * a change moves a role between groups it glides from where it was to where it lands. A name too long - * for its room ends in an ellipsis and says itself whole in its title. + * The grouped profile, under the generator's rows whenever routing is not shown with them: the roles + * grouped by what they run (`profileGroups`: by provider, then by level), each group's + * `model:thinking` then its roles, ● for the agent-backed. When a change moves a role between groups + * it glides from where it was to where it lands. A name too long for its room ends in an ellipsis and + * says itself whole in its title. */ export function Profile({ groups }: { groups: readonly ProfileGroup[] }) { const box = useRef(null); @@ -171,7 +172,7 @@ export function Profile({ groups }: { groups: readonly ProfileGroup[] }) { known.current = new Set(leads.map(lead => lead.dataset.group!)); places.current = after; }, [key]); - return
+ return
{groups.map(group => diff --git a/plugins/code/generator/styles.css b/plugins/code/generator/styles.css index e16ebac..e414f46 100644 --- a/plugins/code/generator/styles.css +++ b/plugins/code/generator/styles.css @@ -6,8 +6,9 @@ * is colour, motion and simple block bars; no capsules, no grounds behind words, no glows. Widths * answer the panel's own (`data-mode`, set from its measured width, and container queries on the * `code` container), never the viewport, and every track's minimum fits inside the panel - * (`minmax(0, …)`, or a share of the stage) so nothing scrolls sideways. The prototype's quieter - * greys are raised to 4.5:1 on the panel ground. + * (`minmax(0, …)`, or a share of the stage) so nothing scrolls sideways; below 760px the panel's + * height decides whether routing and usage stand under the generator (`data-stacked`) or are tabs. + * The prototype's quieter greys are raised to 4.5:1 on the panel ground. * Sections: panel · palette · stage · generator rows · readout, profile, meters and launch · * routing · key line · widths · motion · session options · sheets · sessions. */ @@ -299,7 +300,7 @@ .plugin-atyrode_code_generator .plugin-atyrode_code_generator__readout b { color: var(--rc, var(--tui-acc)); font-weight: 650; } .plugin-atyrode_code_generator .plugin-atyrode_code_generator__readout[data-tone="warn"] { color: var(--tui-warn-text); } .plugin-atyrode_code_generator .plugin-atyrode_code_generator__readout[data-tone="warn"] b { color: var(--tui-warn); } -/* The narrow profile: the team grouped by what it runs, whenever routing is not beside the generator. */ +/* The grouped profile: the roles grouped by what they run, whenever routing is not shown with the generator. */ /* Either column gives way before anything is cut: a lead or a role too long for its room ends in an ellipsis, its title whole. */ .plugin-atyrode_code_generator .plugin-atyrode_code_generator__profile { display: grid; grid-template-columns: minmax(0, max-content) minmax(0, 1fr); gap: 2px 18px; margin: 14px 0 0 calc(16px + 24px); } .plugin-atyrode_code_generator .plugin-atyrode_code_generator__profile-lead { min-width: 0; overflow: hidden; color: var(--h); line-height: 22px; text-overflow: ellipsis; white-space: nowrap; } @@ -458,6 +459,14 @@ .plugin-atyrode_code_generator .plugin-atyrode_code_generator__tui[data-mode="narrow"] .plugin-atyrode_code_generator__meter { grid-template-columns: 12px calc(22px + var(--labw)) auto; } .plugin-atyrode_code_generator .plugin-atyrode_code_generator__tui[data-mode="narrow"] .plugin-atyrode_code_generator__launchrow { grid-template-columns: 12px minmax(0, 1fr); } .plugin-atyrode_code_generator .plugin-atyrode_code_generator__tui[data-mode="narrow"] .plugin-atyrode_code_generator__profile { margin-left: calc(12px + 22px); } +/* + * Narrow and tall enough (`data-stacked`, web.tsx `useStacked`): one column, routing under the generator + * and usage under routing, each the panel's width, a rule above each as usage has at every width. + */ +.plugin-atyrode_code_generator .plugin-atyrode_code_generator__tui[data-stacked] .plugin-atyrode_code_generator__stage { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto auto 1fr; } +.plugin-atyrode_code_generator .plugin-atyrode_code_generator__tui[data-stacked] .plugin-atyrode_code_generator__pane[data-pane="routing"] { grid-column: 1; grid-row: 2; border-top: 1px solid var(--tui-rule); } +.plugin-atyrode_code_generator .plugin-atyrode_code_generator__tui[data-stacked] .plugin-atyrode_code_generator__pane[data-pane="routing"]::before { display: none; } +.plugin-atyrode_code_generator .plugin-atyrode_code_generator__tui[data-stacked] .plugin-atyrode_code_generator__pane[data-pane="usage"] { grid-row: 3; } /* Under 430px the lead's words drop under its label, so the leads and the `only` box keep one line. */ @container code (width < 430px) { .plugin-atyrode_code_generator .plugin-atyrode_code_generator__tui[data-mode="narrow"] { --gap: 8px; --pad: 14px; font-size: 13px; } diff --git a/plugins/code/generator/web.tsx b/plugins/code/generator/web.tsx index 96bb913..31c1f89 100644 --- a/plugins/code/generator/web.tsx +++ b/plugins/code/generator/web.tsx @@ -35,9 +35,13 @@ const PANEL_ROOT = ".plugin-atyrode_code_generator"; /** The session options sheet's title; Models and Setup draw their own heads (models-sheet.tsx, setup-sheet.tsx). */ const OPTIONS_TITLE = "Session options"; const NO_WORDS: StatementWords = { lane: "", tier: "", thinking: "", advisor: "", extras: "", machine: "" }; -/** Below this panel width the generator stands alone, routing and usage a key away; from the second, the wider spacing. */ +/** Below this panel width routing and usage leave the generator's side, for under it or tabs of their own; from the second, the wider spacing. */ const NARROW_BELOW_PX = 760, WIDE_FROM_PX = 1180; type Mode = "narrow" | "medium" | "wide"; +/** The layout the panel's width allows and, below 760px, the panel's size, which decides where routing and usage stand (`useStacked`). */ +type Panel = { readonly mode: Mode; readonly width: number; readonly height: number }; +/** How far the stacked arrangement may run past the panel's height before it gives way, so rounding never flips it back and forth. */ +const STACKED_GRACE_PX = 4; /** One polite live region, mounted empty; an identical message is announced again because its node is replaced. */ function useAnnouncer(): { region: ReactElement; announce: (text: string) => void } { @@ -58,34 +62,123 @@ function scrollParent(element: HTMLElement | null): HTMLElement | null { } /** - * The layout the panel's own width allows, judged before paint and on every resize: the panel, not - * the window, since Code shares the window with its canvas. The first layout and every resize place + * The layout the panel's own size allows, judged before paint and on every resize: the panel, not + * the window, since Code shares the window with its canvas. The width chooses the layout; below 760px + * the width and the scroll region's height are kept too, for `useStacked`, and wider a resize renders + * nothing new. The region is found by its own class, since its viewport only declares itself + * scrolling once mounted. The first layout, every resize and every `still` change of arrangement place * things without motion (`data-boot`), so nothing glides into its place on arrival. */ -function useMode(app: RefObject): Mode { - const [mode, setMode] = useState("wide"); +function usePanel(app: RefObject): { panel: Panel; still: () => void } { + const [panel, setPanel] = useState({ mode: "wide", width: 0, height: 0 }); + const motion = useRef({ booted: false, frame: 0 }); + const still = useCallback(() => { + const node = app.current, state = motion.current; + if (!node) return; + node.dataset.boot = ""; + // Before the fonts are ready the first layout is still settling, and lifts `data-boot` itself once they are. + if (!state.booted) return; + cancelAnimationFrame(state.frame); + state.frame = requestAnimationFrame(() => { state.frame = requestAnimationFrame(() => { delete node.dataset.boot; }); }); + }, []); useLayoutEffect(() => { - const node = app.current; + const node = app.current, region = node?.closest(`.${G}scroll`); if (!node) return; node.dataset.boot = ""; - let booted = false, frame = 0; const judge = () => { const width = node.clientWidth; - if (width > 0) setMode(width < NARROW_BELOW_PX ? "narrow" : width < WIDE_FROM_PX ? "medium" : "wide"); + if (width === 0) return; + const mode: Mode = width < NARROW_BELOW_PX ? "narrow" : width < WIDE_FROM_PX ? "medium" : "wide"; + const next = mode === "narrow" ? { mode, width, height: region?.clientHeight ?? 0 } : { mode, width: 0, height: 0 }; + setPanel(previous => previous.mode === next.mode && previous.width === next.width && previous.height === next.height ? previous : next); }; judge(); - const settle = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(() => { frame = requestAnimationFrame(() => { delete node.dataset.boot; }); }); }; - const observer = new ResizeObserver(() => { - if (booted) node.dataset.boot = ""; - judge(); - if (booted) settle(); - }); + const observer = new ResizeObserver(() => { still(); judge(); }); observer.observe(node); + if (region) observer.observe(region); let live = true; - void node.ownerDocument.fonts.ready.then(() => { if (!live) return; booted = true; settle(); }); - return () => { live = false; cancelAnimationFrame(frame); observer.disconnect(); }; + void node.ownerDocument.fonts.ready.then(() => { if (!live) return; motion.current.booted = true; still(); }); + return () => { live = false; cancelAnimationFrame(motion.current.frame); observer.disconnect(); }; }, []); - return mode; + return { panel, still }; +} + +/** + * The generator's height as both arrangements weigh it: without the grouped profile, which only the + * tabbed one shows, and with its head's first line only, since the Show controls that only the + * stacked one adds may wrap the head. + */ +function bareHeight(generator: HTMLElement): number { + const box = (element: Element) => element.getBoundingClientRect(); + const head = generator.querySelector(`:scope > .${G}head`), profile = generator.querySelector(`.${G}profile`); + let height = box(generator).height; + if (head?.firstElementChild) { + const title = box(head.firstElementChild); + height -= box(head).bottom - Math.max(...[...head.children].map(box).filter(item => item.top < title.bottom).map(item => item.bottom)); + } + if (profile) height -= box(profile).height + parseFloat(getComputedStyle(profile).marginTop); + return height; +} + +/** What the stage's arrangement below 760px depends on besides the panel's size. */ +type StageFacts = { + /** The main view shows, so the generator can be measured: no other view, no sheet over it. */ + readonly main: boolean; + /** The routing's fallback chains show. */ + readonly chains: boolean; + /** The routing's rows; none while the profile is read, when there is nothing yet to weigh. */ + readonly routes: number; + /** Routing is hidden beside or under the generator. */ + readonly hidden: boolean; +}; + +/** + * Below 760px, where routing and usage stand: under the generator when the generator without its + * grouped profile and the whole routing pane fit the panel's height together (its scroll region's, + * less the key line), otherwise as tabs of their own, as they also are until there are routes to + * weigh. It is judged from what is rendered, and only when the panel's size or the stage's structure + * changes (entering narrow, the routing's rows or the generator's, the fallback chains, routing hidden + * or shown, a return to the main view), never because the readout or the launch line says something + * else: a later overflow scrolls, and the launch never moves under the pointer. Both arrangements + * weigh the same heights: the generator by `bareHeight`, and the bar and routing as the stacked + * arrangement last laid them out at this width with these chains and routes. Where it has not yet, + * the stacked arrangement is tried before paint (`probing`, routing shown even if hidden) and settles + * back if it does not fit. A change of arrangement is `still`, as a resize is, and focus on a routing + * or usage control that goes is kept in the panel (`keep`). + */ +function useStacked(app: RefObject, panel: Panel, still: () => void, facts: StageFacts, keep: () => void): { stacked: boolean; probing: boolean } { + const [state, setState] = useState({ stacked: false, probing: false }); + // The bar and routing's height as the stacked arrangement laid them out, by width, chains and routes. + const laid = useRef(new Map()); + const judged = useRef(null); + useLayoutEffect(() => { + const node = app.current; + const generator = node?.querySelector(`[data-pane="generator"]`), routing = node?.querySelector(`[data-pane="routing"]`); + if (panel.mode !== "narrow" || !facts.main || facts.routes === 0 || !node || !generator || !routing) { judged.current = null; return; } + const at = `${panel.width}:${facts.chains}:${facts.routes}`; + const key = `${at}:${panel.height}:${facts.hidden}:${generator.querySelectorAll("[data-row]").length}`; + if (key === judged.current && !state.probing) return; + judged.current = key; + const height = (element: Element | null) => element?.getBoundingClientRect().height ?? 0; + if ((state.stacked || state.probing) && !routing.hidden) laid.current.set(at, height(node.querySelector(`.${G}bar`)) + height(routing)); + const frame = laid.current.get(at); + // Focus on a routing or usage control would be lost with it: its pane hides when the stage turns to tabs, its tab goes when it stacks or is tried. + const leaving = node.ownerDocument.activeElement?.closest(`[data-pane="routing"], [data-pane="usage"], [data-view-tab="routing"], [data-view-tab="usage"]`); + if (frame === undefined) { + still(); + if (leaving && !state.stacked) keep(); + setState({ stacked: state.stacked, probing: true }); + return; + } + const stacked = frame + bareHeight(generator) <= panel.height - height(node.querySelector(`.${G}keys`)) + (state.stacked ? STACKED_GRACE_PX : 0); + if (stacked === state.stacked && !state.probing) return; + if (stacked !== state.stacked) { + still(); + if (leaving) keep(); + } + setState({ stacked, probing: false }); + }); + return state; } /** The keys that can open a view tab: Esc goes back to the generator, or from Manage accounts to the accounts. */ @@ -98,7 +191,7 @@ const SHORTCUTS: readonly { readonly title: string; readonly keys: readonly (rea ["d", "Defaults"], ["z", "Back to the saved profile"], ["f", "Show or hide the fallback chains, here or in Routing"], ["w", "Choose the machine"], ["m", "Models"], ["u", "Setup"], ["o", "Session options"]] }, { title: "Views", keys: [["a", "Accounts, or back to the generator"], ["e", "Sessions, or back to the generator"], - ["p", "Routing: hide or show it beside the generator, or open it when narrow"], ["s", "Usage: hide or show it beside the generator, or open it when narrow"], + ["p", "Routing: hide or show it beside or under the generator, or open its tab"], ["s", "Usage: hide or show it under the generator, or open its tab"], ["Esc", "Back to the generator; from Manage accounts, back to the accounts"]] }, { title: "Accounts", keys: [["↑ ↓", "Move between the accounts"], ["Space", "Include or exclude the account"], ["m", "Manage accounts: pools, sign-in, credentials"]] }, { title: "Sessions", keys: [["1–9", "Recall the recent profile with that number"]] }, @@ -129,8 +222,10 @@ type WorkbenchProps = { * The panel as the terminal UI had it, evolved for the web: the generator's rows with routing * beside them and usage under both when there is room, a key line at the foot, and the accounts and * the sessions one key away; under the accounts, their management (sign-in, presets, credentials). - * Under 760px the generator stands alone, the team grouped under its rows, routing and usage each a - * key away. Models, Setup and the session options open as sheets over the stage. + * Under 760px routing stands under the generator and usage under routing when the panel is tall + * enough for the generator and the whole routing at once (`useStacked`); otherwise routing and usage + * are tabs of their own and the profile shows grouped under the generator's rows. Models, Setup and + * the session options open as sheets over the stage. */ function Workbench({ host, target, machine, machines, rosterError, available, select, refreshMachines, presence }: WorkbenchProps) { const model = useWorkbench({ host, target, machine, rosterError, available }); @@ -140,7 +235,8 @@ function Workbench({ host, target, machine, machines, rosterError, available, se const { region, announce } = useAnnouncer(); const app = useRef(null); const generator = useRef(null); - const mode = useMode(app); + const { panel, still } = usePanel(app); + const { mode } = panel; const narrow = mode === "narrow"; const [view, setView] = useState("main"); const viewRef = useRef(view); @@ -192,13 +288,18 @@ function Workbench({ host, target, machine, machines, rosterError, available, se const accent = !lane || lane.kind === "mixed" ? "var(--tui-mixed)" : `var(--code-${hueOf(lane.family)})`; // ------------------------------------------------------------ views and sheets - const showRouting = (view === "main" && !narrow && !hidden.routing) || view === "routing"; - const showUsage = (view === "main" && !narrow && !hidden.usage) || view === "usage"; + const arrangement = useStacked(app, panel, still, { main: view === "main" && sheet === null, chains: chains && fallbacks, routes: ledger?.length ?? 0, + hidden: hidden.routing }, keepFocus); + // Narrow, routing and usage stand under the generator (`stacked`) or are tabs of their own (`tabbed`). + const stacked = narrow && (arrangement.stacked || arrangement.probing); + const tabbed = narrow && !stacked; + const showRouting = (view === "main" && !tabbed && (!hidden.routing || arrangement.probing)) || view === "routing"; + const showUsage = (view === "main" && !tabbed && !hidden.usage) || view === "usage"; function changeView(next: PanelView) { if (next !== view) setView(next); } - // A width that brings routing and usage back beside the generator closes them as views of their own. - useEffect(() => { if (!narrow && (view === "routing" || view === "usage")) setView("main"); }, [narrow]); + // An arrangement that brings routing and usage back beside or under the generator closes their tabs. + useEffect(() => { if (!tabbed && (view === "routing" || view === "usage")) setView("main"); }, [tabbed]); // A pane that comes into view slides in; one that leaves simply goes. const shownPanes = useRef>(new Set()); useLayoutEffect(() => { @@ -369,7 +470,7 @@ function Workbench({ host, target, machine, machines, rosterError, available, se inField: target?.matches("textarea, input, select, [contenteditable]") ?? false, inDialog: target?.closest("[role=dialog], dialog, [data-popover]") != null, onControl: target?.closest("button, a, [role=listbox], [role=option]") != null, - onRoot, view, narrow, recents: recents.length, + onRoot, view, tabbed, recents: recents.length, }); if (action && run(action)) { event.preventDefault(); event.stopPropagation(); } }; @@ -394,7 +495,7 @@ function Workbench({ host, target, machine, machines, rosterError, available, se // ------------------------------------------------------------ the bar, the pane heads and the key line: every action a control, keys its accelerators // A control names its key only where the panel's key rules give that key its action in the shown view (panel-keys.ts). const keyed = (title: string, keys: readonly string[], does: (action: PanelAction) => boolean) => { - const key = acceleratorFor(view, narrow, keys, does); + const key = acceleratorFor(view, tabbed, keys, does); return { "aria-keyshortcuts": key ?? undefined, title: key === null ? title : `${title} (${key === "Escape" ? "Esc" : key})` }; }; const runsMark = tabMark(model.agentRuns.runs?.runs ?? []); @@ -416,7 +517,7 @@ function Workbench({ host, target, machine, machines, rosterError, available, se event.preventDefault(); event.stopPropagation(); } - const menuKey = (keys: readonly string[], does: (action: PanelAction) => boolean) => acceleratorFor(view, narrow, keys, does); + const menuKey = (keys: readonly string[], does: (action: PanelAction) => boolean) => acceleratorFor(view, tabbed, keys, does); const isSheet = (sheet: PanelSheet) => (action: PanelAction) => action.kind === "sheet" && action.sheet === sheet; // A staged model list beside the active one changes nothing until it is reviewed in Models; More and its Models item say it waits there. const commands: readonly MenuCommand[] = [ @@ -431,14 +532,14 @@ function Workbench({ host, target, machine, machines, rosterError, available, se const bar = ; - const hideButton = (pane: "routing" | "usage") => !narrow && ; // The short key line: the keys the shown view answers most, every other one under Shortcuts. Hidden for a coarse pointer, which has no keys. const viewKeys: readonly (readonly [string, string])[] = view === "main" @@ -454,7 +555,7 @@ function Workbench({ host, target, machine, machines, rosterError, available, se ); // ------------------------------------------------------------ the stage - const tui =