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..2cef145 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -244,21 +244,21 @@ 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 -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 -or focus leaving closes it. Choosing returns focus to More, where a sheet or dialog -gives it back. **Models**, **Setup** and **Options** open their sheets (`m`, `u`, `o` -from the main view, where the menu shows each key beside its item). While a staged model -list waits beside the active one, More reads **More · staged** and its item **Models · -staged**, since nothing changes until it is reviewed there. When session options are -set, More and its **Options** item carry their summary ("· restricted", "· skills off", -"· 2 skills"). **Shortcuts** opens a native modal dialog that lists every key under the -view it acts in (Generator, Views, Accounts, Sessions, Anywhere), with `m`, `u` and `o` -under Generator, where they act; `Esc` or its Close button closes it and it owns its -keys while open. +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 or focus leaving closes it. +Choosing returns focus to More, where a sheet or dialog gives it back. **Models**, +**Setup** and **Options** open their sheets (`m`, `u`, `o` from the main view, where the +menu shows each key beside its item). While a staged model list waits beside the active +one, More reads **More · staged** and its item **Models · staged**, since nothing changes +until it is reviewed there. When session options are set, More and its **Options** item +carry their summary ("· restricted", "· skills off", "· 2 skills"). **Shortcuts** opens a +native modal dialog that lists every key under the view it acts in (Generator, Views, +Accounts, Sessions, Anywhere), with `m`, `u` and `o` under Generator, where they act; +`Esc` or its Close button closes it and it owns its keys while open. The workbench reads its inputs again on its own: the workspace profile, OMP's setup, defaults, bundled model list, skills and accounts, and the machine list, once a minute @@ -365,10 +365,13 @@ 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. +Within a provider they read by level: the higher tier first (elite, smart, normal, fast), +then the higher thinking, and last by alias, so equal levels always stand in one order. 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,27 +510,28 @@ 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 -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: -`tight` from 80% used (or when the provider warns), `maxed` when exhausted, `blocked` -for a provider block, whose `↻` is the time it lifts. Windows are never averaged across -accounts or providers. Every reported window is a row. A window with a tier meters a -limit of its own beside the account's shared windows, such as Anthropic's weekly limit -for one model kind or a Codex named limit, and its label names the tier (`7d fable`, -`7d base-model-inference`) so it is told apart from the shared `7d`. Only the shared -windows judge the provider's pool: OMP does not yet say whether a tiered limit stops -every route of the account or only one model's, so a used-up `fable` window makes -Claude neither tight nor maxed. A prepaid balance is shown in its own currency; an account -with no windows says "no windows reported"; a block on a scope no shown window meters -is its own line (" requests blocked ↻ Sat 16:55"). An account excluded from the -pool, or whose credential is disabled, is drawn in quieter colours. An account read -from a stale source or longer ago than the five-minute freshness window says its age -("12m old") and is never drawn as current; a provider whose accounts are all history -says it once. Where nothing can be drawn the pane says "no account choices yet", "not -read yet", "accounts unavailable" or "no accounts". **Hide** in the head (`s`) removes -the pane; **Show usage** in the generator's head brings it back. +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: `tight` from 80% used (or when the +provider warns), `maxed` when exhausted, `blocked` for a provider block, whose `↻` is the +time it lifts. Windows are never averaged across accounts or providers. Every reported +window is a row. A window with a tier meters a limit of its own beside the account's +shared windows, such as Anthropic's weekly limit for one model kind or a Codex named +limit, and its label names the tier (`7d fable`, `7d base-model-inference`) so it is +told apart from the shared `7d`. Only the shared windows judge the provider's pool: OMP +does not yet say whether a tiered limit stops every route of the account or only one +model's, so a used-up `fable` window makes Claude neither tight nor maxed. A prepaid +balance is shown in its own currency; an account with no windows says "no windows +reported"; a block on a scope no shown window meters is its own line (" requests +blocked ↻ Sat 16:55"). An account excluded from the pool, or whose credential is +disabled, is drawn in quieter colours. An account read from a stale source or longer ago +than the five-minute freshness window says its age ("12m old") and is never drawn as +current; a provider whose accounts are all history says it once. Where nothing can be +drawn the pane says "no account choices yet", "not read yet", "accounts unavailable" or +"no accounts". **Hide** in the head (`s`) removes the pane; **Show usage** in the +generator's head brings it back. Its last line reads `next refresh 4:54` with a **Refresh now** button (`r`). Code reads the usage again every five minutes, its freshness window: the host's feeds read again @@ -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,20 @@ 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 -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. +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, without +its fallback chains, 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, once a resize has settled and when the stage's structure changes (the routes or +the generator's rows, routing hidden or shown, a return to the generator), never when the +readout, the launch line or the fallback chains change: what then runs past the panel +scrolls, so the launch never moves under the pointer and the chains never send routing +back to a tab. 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, +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..46fada3 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, because 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..cc18b6a 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, 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,16 @@ 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 whatever the locale. 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 +88,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 < b.lead.alias ? -1 : a.lead.alias > b.lead.alias ? 1 : 0)); } diff --git a/plugins/code/generator/routing-pane.tsx b/plugins/code/generator/routing-pane.tsx index 11c13c4..5564edb 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..bcfb48e 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,31 @@ .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); } +/* + * When the panel is narrow but tall enough (`data-stacked`, web.tsx `useStacked`), the stage is one + * column: routing stands under the generator and usage under routing, each as wide as the panel. + * Routing then has a rule above it, as usage has at every width, in place of its rule beside the + * generator. + */ +.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; } +/* + * While the stacked arrangement is tried before paint (`data-probing`), the Routing and Usage tabs + * keep their nodes, so focus on one of them stays, but take no room, so the bar measures as the + * stacked arrangement's own. The probe is never painted. + */ +.plugin-atyrode_code_generator .plugin-atyrode_code_generator__tui[data-probing] .plugin-atyrode_code_generator__tab:is([data-view-tab="routing"], [data-view-tab="usage"]) { + position: absolute; + width: 1px; + height: 1px; + min-height: 0; + margin: 0; + padding: 0; + overflow: hidden; + clip-path: inset(50%); +} /* 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..74ef3e1 100644 --- a/plugins/code/generator/web.tsx +++ b/plugins/code/generator/web.tsx @@ -35,9 +35,16 @@ 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`); `fonts` turns true once the document's fonts are ready, so what a fallback font laid out is measured again. + */ +type Panel = { readonly mode: Mode; readonly width: number; readonly height: number; readonly fonts: boolean }; +/** 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 +65,155 @@ 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 - * things without motion (`data-boot`), so nothing glides into its place on arrival. + * The layout the panel's own size allows: the panel, not the window, since Code shares the window + * with its canvas. The width chooses the layout at once, before paint and on every resize. Below + * 760px the width and the scroll region's height are kept too, for `useStacked`, but only once a + * resize has settled (two frames without another, as `data-boot` lifts), so a drag decides where + * routing stands once rather than at every pixel; 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, fonts: false }); + const motion = useRef({ booted: false, frame: 0, settled: () => {} }); + 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; state.settled(); }); }); + }, []); 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 = () => { + // A new layout takes its size at once; within one, a new size waits until the resize has settled. + const judge = (settled: boolean) => { 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 narrow = mode === "narrow"; + setPanel(previous => { + const next = { mode, width: narrow ? width : 0, height: narrow ? region?.clientHeight ?? 0 : 0, fonts: previous.fonts }; + return previous.mode === mode && (!settled || 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(); - }); + motion.current.settled = () => judge(true); + judge(true); + const observer = new ResizeObserver(() => { still(); judge(false); }); 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; + setPanel(previous => ({ ...previous, fonts: 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 without the Show controls (`data-reveal`) that only the stacked one adds at + * the end of its head, where they may take a line of their own. + */ +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) height -= box(head).bottom - Math.max(...[...head.children].filter(child => !child.matches("[data-reveal]")).map(child => box(child).bottom)); + if (profile) height -= box(profile).height + parseFloat(getComputedStyle(profile).marginTop); + return height; +} + +/** + * Routing's height without its fallback chains, as laid out now: the chains (`f`) never decide where + * routing stands, so the lines each route's chain runs past its lead are left out, whether it runs + * beside the role or, under 300px, under it. + */ +function bareRoutingHeight(routing: HTMLElement): number { + let height = routing.getBoundingClientRect().height; + for (const chain of routing.querySelectorAll(`.${G}chain`)) { + const lead = chain.firstElementChild; + if (lead) height -= chain.getBoundingClientRect().bottom - lead.getBoundingClientRect().bottom; + } + 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 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 without its fallback chains 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 + * settled size or the stage's structure changes (entering narrow, the routing's rows or the + * generator's, routing hidden or shown, a return to the main view, the fonts becoming ready), never + * because the readout, the launch line or the fallback chains changed: what then runs past the panel + * scrolls, so the launch never moves under the pointer and the chains never send routing back to a + * tab. Both arrangements weigh the same heights: the generator by `bareHeight`, and the bar and + * routing (`bareRoutingHeight`) as the stacked arrangement last laid them out at this width with + * these routes. Where it has not yet, the stacked arrangement is tried before paint (`probing`: + * routing shown even if hidden, and the Routing and Usage tabs kept for focus but given no room) and + * settles back if it does not fit; a flip to stacked is measured again as laid out. A change of + * arrangement is `still`, as a resize is, and focus on a routing or usage control it hides or removes + * 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's and routing's height as the stacked arrangement laid them out, by width and routes, and whether the fonts were ready. + // The bar can gain a line later (More's aside wrapping at a narrow width) without a new key; that only scrolls, as any later + // overflow does, until the next resize or structural change judges again. + const laid = useRef({ fonts: false, heights: new Map() }); + const judged = useRef(null); + useLayoutEffect(() => { + if (panel.mode !== "narrow" || !facts.main || facts.routes === 0) { judged.current = null; return; } + const node = app.current; + const generator = node?.querySelector(`[data-pane="generator"]`), routing = node?.querySelector(`[data-pane="routing"]`); + if (!node || !generator || !routing) return; + if (laid.current.fonts !== panel.fonts) laid.current = { fonts: panel.fonts, heights: new Map() }; + const at = `${panel.width}:${facts.routes}`; + const key = `${at}:${panel.height}:${facts.hidden}:${generator.querySelectorAll("[data-row]").length}:${panel.fonts}`; + if (key === judged.current && !state.probing) return; + judged.current = key; + // A probe whose routing did not show cannot be measured; it ends rather than asks again for the same key. + if (state.probing && routing.hidden) { + setState({ stacked: state.stacked, probing: false }); + return; + } + const height = (element: Element | null) => element?.getBoundingClientRect().height ?? 0; + if ((state.stacked || state.probing) && !routing.hidden) laid.current.heights.set(at, height(node.querySelector(`.${G}bar`)) + bareRoutingHeight(routing)); + const frame = laid.current.heights.get(at); + if (frame === undefined) { + still(); + 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) { + still(); + // Focus on a routing or usage control would go with it: its pane hides when the stage turns to tabs, its tab goes when it stacks. + if (node.ownerDocument.activeElement?.closest(`[data-pane="routing"], [data-pane="usage"], [data-view-tab="routing"], [data-view-tab="usage"]`)) keep(); + // A stacked arrangement taken from what an earlier layout measured is measured once more as it is laid out now. + if (stacked) judged.current = null; + } + if (stacked !== state.stacked || state.probing) 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 +226,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 +257,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 +270,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 +323,21 @@ 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, routes: ledger?.length ?? 0, hidden: hidden.routing }, keepFocus); + // Narrow, routing and usage stand under the generator (`stacked`) or are tabs of their own (`tabbed`); a probe lays them out + // stacked before paint while the tabs keep their nodes (`probing`), so focus on one stays unless the stage really stacks. + const probing = narrow && arrangement.probing; + const stacked = narrow && (arrangement.stacked || probing); + const tabbed = narrow && !arrangement.stacked; + // Routing and usage stand with the generator: beside it, or under it. + const withGenerator = !narrow || stacked; + const showRouting = (view === "main" && withGenerator && (!hidden.routing || probing)) || view === "routing"; + const showUsage = (view === "main" && withGenerator && !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 +508,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 +533,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 +555,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 +570,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 +593,8 @@ function Workbench({ host, target, machine, machines, rosterError, available, se ); // ------------------------------------------------------------ the stage - const tui =