From 657623221a0f04ebef37bea597d2143ef25e0d2e Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Tue, 13 Oct 2026 11:59:38 +0000 Subject: [PATCH 1/8] A Launch that waits on the person opens a dialog; press outcomes reach the notice stack --- README.md | 14 +- docs/configuration.md | 122 +++++++++++------- plugins/code/generator/decision-dialog.tsx | 71 ++++++++++ plugins/code/generator/decision-model.ts | 84 ++++++++++++ plugins/code/generator/generator-pane.tsx | 111 ++++++++++------ plugins/code/generator/machine-picker.tsx | 6 +- plugins/code/generator/model-verification.ts | 4 +- plugins/code/generator/models-sheet.tsx | 17 +-- plugins/code/generator/statement-model.ts | 128 +++++++++++-------- plugins/code/generator/styles.css | 42 +++++- plugins/code/generator/web.tsx | 17 ++- plugins/code/generator/workbench-model.ts | 16 ++- plugins/test/decision-model.test.ts | 45 +++++++ plugins/test/statement-model.test.ts | 33 +---- 14 files changed, 495 insertions(+), 215 deletions(-) create mode 100644 plugins/code/generator/decision-dialog.tsx create mode 100644 plugins/code/generator/decision-model.ts create mode 100644 plugins/test/decision-model.test.ts diff --git a/README.md b/README.md index 136b3d3..1d4d129 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,7 @@ Code owns custom launch and continuation choices; OMP supplies the runtime primitives that execute them. Moving the launcher into Manifold does not move its product policy into OMP. The retained continuation scope is native fleet metadata, exact terminal reopening, native resume and explicit next-resume -choices. The skill, automation and continuation additions are source-only, -not included in a published release; see [availability boundaries](docs/status.md#explicit-availability-boundaries). +choices. Portable archives, general live settings-origin inspection, live per-account quota preservation and Code-owned upstream fallback repair are not planned. @@ -42,10 +41,13 @@ quota preservation and Code-owned upstream fallback repair are not planned. including to quota where it strands a role or leads on a strained pool. A lane for a signed-in family the model list lacks is struck, and pressing it checks the models, which finds them. Cost and speed meters sit above a launch row that names the next step - (Launch, Save & launch, Review) beside a dropdown of the machines it can run + (Launch, Save & launch) beside a dropdown of the machines it can run on; a machine without OMP is refused like an offline one. When the step is refused it 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 + profile, OMP missing from the machine) with a fix; pressing it anyway opens a dialog + with the reason, why it applies and the same fix. Whatever a press waits on the person + for (a check's charge, a review that differs) is asked in such a dialog, and how a + press ended also reaches Manifold's notice stack. **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 stand under the generator when the panel is tall enough for the generator and the whole routing at once, and are tabs of @@ -65,7 +67,7 @@ quota preservation and Code-owned upstream fallback repair are not planned. - **Model checks:** a launch needs a model list checked against the account pool, and Launch runs that check itself when the list in use cannot serve it (a first launch, a model OMP no longer lists, or a lead the accounts no longer serve), then - goes on to the launch. The first check asks the person to approve its + goes on to the launch. The first check asks the person, in a dialog, to approve its cost, once: later checks within twice that charge run without asking, and Setup can withdraw the approval. A finished check is kept as that person's check of the exact account pool, so another workspace whose choices select the same pool adopts it on its @@ -74,7 +76,7 @@ quota preservation and Code-owned upstream fallback repair are not planned. list that passed meanwhile. - **Agent launch:** the launch is one press, with no review step: the press reviews the session immediately before starting it, and stops only when what would run differs - from what the panel showed, saying what differs. Where an Agent Run can carry it, + from what the panel showed, asking in a dialog whether to launch it. Where an Agent Run can carry it, Code registers one Manifold Agent per sponsor and workspace profile with OMP's TUI harness, updates it when the profile changes, and launches a Run whose terminal is OMP's own terminal UI. **Sessions** heads each machine's group with these Runs: their diff --git a/docs/configuration.md b/docs/configuration.md index 42f99cd..b678480 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -334,7 +334,7 @@ while the panel shows and once when it shows again (its page returns or the pane back on screen) unless it read in the last 15 seconds. A minute keeps a panel left open current within a glance's patience for a handful of small reads, and a hidden panel reads nothing. Such a read never lands in the middle of something: while a step runs or -a charge waits, a sheet, Shortcuts, More or the machine list is open, a row is being +a charge waits, a sheet, Shortcuts, a question, More or the machine list is open, a row is being scrubbed, a text field is in use, an account change is saving, or within four seconds of a key or a press in the panel, it waits and happens once that is over. An edit left unsaved holds it however long it is left, focused or not: a changed generator row until @@ -379,7 +379,8 @@ routing or usage is hidden, **Show routing** and **Show usage** stand in this he family someone has signed in for but the model list has no model of keeps its lead, struck, with "No models in your model list"; pressing it checks the models (through the check's own gate, spending nothing before - Approve unless the person approved checks) and the readout says it finds the models the + its charge's question is approved, unless the person approved checks) and the readout + says it finds the models the accounts reach. - **Model** is the capability tier (fast, normal, smart, elite), each tier read as the model alias its `default` role would lead on in the profile that choosing it forms, @@ -470,9 +471,9 @@ launch row; click, or `w`, opens it with focus in the list. `↑` `↓`, `Home`, `↵` or `Space` choose; `Esc`, `Tab` or a press outside closes it and returns focus to the dropdown. While open the list owns its keys, so the panel's keys do nothing. Choosing goes through the edit-machine gate: while a step runs or a charge waits the -dropdown refuses with the gate's reason and leaves the Tab order, so Approve is -the next Tab stop after the launch. An offline or access-revoked machine is struck and -refuses with its reason, and so is a machine OMP is not installed on ("no OMP here"), +dropdown refuses with the gate's reason, as a locked row does. An offline or +access-revoked machine is struck and refuses with its reason, and so is a machine OMP +is not installed on ("no OMP here"), which can run nothing. Code checks every online machine for OMP, and a browser starts on the destination it saved in the tab if OMP answers there, else on the first online machine where it does; with none, the saved one or the first online machine stays @@ -482,10 +483,11 @@ every online machine has answered. The readout says what the pointed machine is the team, and is not recorded in recent profiles. **The verb.** The launch is one control whose `data-state` is `ready`, `busy`, -`waiting` (a check's charge waits on its own Approve) or `refused`. It is `aria-disabled` +`waiting` (a check's charge waits on its dialog) or `refused`. It is `aria-disabled` rather than disabled and stays focusable, so a step changing its state never drops focus, and a launch-line control that leaves once pressed hands focus to the launch; -pressing a refused one shakes it, says the reason in the readout and announces it. A +pressing a refused one asks its question (below), and pressing it while a step runs +shakes it and says why in the readout. A read-only workspace (no `containers:write`) is stated in neutral grey, "Read-only workspace" (its readout adds "changes stay a local preview"). Write access is the caps alone: without a canvas beside the panel, checking models, saving and account edits still @@ -503,22 +505,43 @@ revision is observed (only **Save** where no canvas can place a terminal). **One press.** A launch reviews the saved team's session on the destination immediately before it starts it, in the same press (**launching…** throughout), and starts it when the review shows what the panel showed: the same lead for every role and the same -providers in the account pool. A review that differs stops the press and is held: the -line says "What will run differs" and what differs (roles' leads, providers that joined -or left the pool, or that the accounts were not known before), with "launch again to run -it, or change a setting first"; while the launch is pointed the readout says where it -runs and its pool ("runs on : GPT 2"). The next press launches the held review -while the press's own inputs hold (team, options, machine, the saved profile and +providers in the account pool. A review that differs stops the press and is held, and its +question asks "What will run differs" with what differs (roles' leads, providers that +joined or left the pool, or that the accounts were not known before), **Launch it** and +**Not now**; while the launch is pointed the readout says where it runs and its pool +("runs on : GPT 2"). **Launch it** is the launch's own press and launches the held +review while the press's own inputs hold (team, options, machine, the saved profile and authority), a selected skill catalog's revision is the one it was made with, the reads it -rests on succeed and its launch is still the step (the list in use serves it); the first -of them that fails drops it for good, and the press after that reviews again. Accounts -or OMP defaults that moved after it are refused by the launch's own check (below). A press +rests on succeed and its launch is still the step (the list in use serves it); **Not now** +or the first of them that fails drops it for good, and the press after that reviews again. +Accounts or OMP defaults that moved after it are refused by the launch's own check (below). A press stands on the same inputs: a change of one of them while it runs stops it, while an observation that moves meanwhile does not, since the launch reads the accounts and OMP's defaults again itself and refuses when they moved under its review. Choosing a saved session does not stop a launch. Outcome and refusal lines clear whenever the team, machine or account pool changes. +**Questions and notices.** Whatever a press waits on the person for is asked in a modal +dialog over the whole page rather than said beside the launch, so it cannot be missed: a +check's charge (from the launch, Models or a struck lane), a review that differs, a check +that changed the profile, and a refused Launch press. Each question says what it waits on +and why in a sentence, what it is about one fact per line, then its choices as words, the +next action first in the accent, and a way out last (**Cancel**, **Not now** or **Close**), +which `Esc` and a press on the backdrop take and which never spends or launches. A refused +press's question names the refusal (as the launch line's head below, an account read's +failure in its sentence), says why it applies ("Launch opens a terminal on a machine. +Choose one that is online.") and offers the launch line's own fixes before **Close**; a +fix runs and closes it, and a refusal that lifts or changes closes it too. A question +opens with focus on its title, never on a choice, since a charge or a review comes back +while the person may be typing elsewhere: `Tab` reaches the choices, the next action +first. The second click of a double click, a held key and a pointer press in the first +400 ms after it opens answer nothing; closing gives focus back to what had it. Reads on +their own wait while a question is open. How a press ended is also posted to Manifold's +one notice stack, which shows it at the foot of the page and announces it, each outcome +replacing the last: "Launched on . Type your task in its terminal.", "Resumed on +.", a save, a failed step in its words, and a check the person started that +stopped. A check cancelled by the person, or run by the panel on its own, posts nothing. + **Live dials.** An agent launch (see the workflow below) ends "Launched on · **dials**", the fix opening Sessions (`e`); a launch the reviewed session carried ends "Launched on · no live dials: " (auto plans are on, session options @@ -529,15 +552,15 @@ says the same ("live dials in Sessions" or the reason). Setup's sessions row, an **The launch line** sits beside the launch, or under it where there is no room beside it, and keeps to facts and one-press fixes, each -a button; its asides ("changes stay a local preview", "asked once: later checks run on -their own", "checking models in the background", and, while a staged model list waits -beside the active one, "a staged model list waits in Models") are said in the readout -while the launch is pointed or pressed. It is written in this -precedence: a check the person started (or one the launch waits on) running or its -charge, a step in flight, the verb's refusal with its fix, a staged model list the verb -opens, a held review that differs from what the panel showed or a check that changed the -profile, a failure, the last outcome, and roles with no route with the one change that -routes them (said once no check precedes the launch). A model list that +a button; its asides ("changes stay a local preview", "checking models in the +background", and, while a staged model list waits beside the active one, "a staged model +list waits in Models") are said in the readout while the launch is pointed or pressed. It +is written in this precedence: a check the person started (or one the launch waits on) +running, or "Waiting for your approval" while its charge's question is open, a step in +flight, the verb's refusal with its fix, a staged model list the verb opens, a failure, +the last outcome, and roles with no route with the one change that routes them (said +once no check precedes the launch). What a press waits on the person for is asked in its +question, never in the line. A model list that cannot be read joins the line from the verb's refusal on, with its Retry and Models. The line names what failed rather than a general refusal: @@ -579,23 +602,27 @@ accounts can use.", "Accounts changed since the models were checked; Launch chec again first." or "OMP no longer lists a model this profile uses; Launch checks the models again first." The press reads **checking models…**: it puts the person's own newer check of the pool in use when they keep one, spending nothing, or runs OMP's inventory -("Checking which models your accounts can use") and states the charge: "Checking models -spends N tiny requests", per provider. A charge the person's standing approval covers -(up to twice the largest charge they approved) is spent at once; any other waits with -**approve** and cancel, the aside saying it is asked once. A charge of nothing says -"Nothing to check through these accounts" and cannot be approved. Approve is a Tab -stop after the launch, is never focused for the person, and spends only the very charge -it showed: never one of zero requests, never before the press has stayed in its -checking state for 700 ms, and only on a deliberate single press, never the second -click of a double-click or a held key. It records the standing approval (the larger of -the one held and this charge) before spending; one that cannot be recorded still spends -this charge, and the next check asks again. Progress reads "Checking models with your -accounts · N of M requests". When the check is done the press goes on to the launch, -which reviews and starts the saved team as above, unless the check narrowed a setting of -the profile it was pressed on, which stops the press with "The model check changed the -profile" and the settings it moved, as a choice. A check that stopped or was cancelled -says so ("Model check stopped while : ", "Model check cancelled") until -the next one starts; the launch is its retry. +("Checking which models your accounts can use") and answers the charge. A charge the +person's standing approval covers (up to twice the largest charge they approved) is spent +at once; any other is asked: "Check your models first" (from Models, "Check your models"), +why ("Launch needs to know which models your accounts can use, so Code sends each one a +tiny request."), the charge ("29 tiny requests: GPT 8 · Claude 19 · DeepSeek 2", in the +panel's family order), and, for a person who keeps checks, "You approve this once. Later +checks run on their own."; then **Approve and launch** (**Approve** from Models) and +**Cancel**. A charge of nothing asks "Nothing to check" with **Show accounts** and +**Close**, and cannot be approved; every way out of a charge cancels the check, spending +nothing. Approve is never focused for the person, and spends only the very charge it +showed: never one of zero requests, never before the press has stayed in its checking +state for 700 ms, and only on a deliberate single press, never the second click of a +double-click or a held key. It records the standing approval (the larger of the one held +and this charge) before spending; one that cannot be recorded still spends this charge, +and the next check asks again. Progress reads "Checking models with your accounts · N of +M requests". When the check is done the press goes on to the launch, which reviews and +starts the saved team as above, unless the check narrowed a setting of the profile it was +pressed on, which stops the press and asks "The model check changed your profile" with the +settings it moved, **Launch** and **Not now**. A check that stopped or was cancelled says +so ("Model check stopped while : ", "Model check cancelled") until the next +one starts; the launch is its retry. **Background checks.** The panel starts a check on its own when the workspace has been set up, the list in use no longer holds, no staged list waits, the list in use is not an @@ -829,8 +856,9 @@ space include · m manage · esc generator`, in the management `esc accounts`, i sessions `esc generator`, preceded by `↑↓ move · 1–N recall` when recent profiles exist. Words are not buttons, since every action is a control elsewhere. For a coarse pointer, which has no keys, the key line is hidden, and every button, tab and radio is at least -44 px. One polite live region announces outcomes, failures, the charge, recalls and the -reason a pressed refused control gave. +44 px. One polite live region announces recalls, commits and the reason a pressed control +gave; a question announces itself as a dialog, and a press's outcome reaches Manifold's +notice stack, which announces it. 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 @@ -900,9 +928,9 @@ prices, speeds, thinking ranges and reasons appear only in the readout line while a cell, a provider or a left-out model is pointed or focused. One next action sits where the launch sits and follows the step: **check models**, -**checking models…** (drawn with the checking hold the charge keeps), **approve** -(a single deliberate press of the exact charge shown, as in the launch line, recording -the standing approval), **checking…** with the requests measured, then **back to code** +**checking models…** (drawn with the checking hold the charge keeps; while the charge's +question is open over the sheet it reads "waiting for your approval" beside it), +**checking…** with the requests measured, then **back to code** with **check again** beside it, the same check a list that holds can always renew, which spends without asking within the standing approval; a check again that stopped, one the panel ran on its own included, reads **check stopped** beside it, with its diff --git a/plugins/code/generator/decision-dialog.tsx b/plugins/code/generator/decision-dialog.tsx new file mode 100644 index 0000000..5b96f88 --- /dev/null +++ b/plugins/code/generator/decision-dialog.tsx @@ -0,0 +1,71 @@ +import { useId, useLayoutEffect, useRef, type KeyboardEvent } from "react"; +import type { Decision, DecisionChoice } from "./decision-model.ts"; + +/** Generator-panel class prefix; every part hangs from the generator root (styles.css). */ +const G = "plugin-atyrode_code_generator__"; +/** + * How long a pointer press is ignored once the dialog opens: one that lands this soon was aimed at + * what stood there before, as a click on Launch the moment its review or check comes back. + */ +export const DECISION_SETTLE_MS = 400; + +/** + * THE QUESTION A PRESS WAITS ON, in a modal dialog over the whole page, so it cannot be missed + * beside the launch. It opens with focus on its title, never on a choice: a check's charge or a + * review comes back while the person may be typing elsewhere, and no key meant for something else + * may answer it. Tab reaches the choices, the next action first. Esc and a press on the backdrop take + * the way out (the last choice), which never spends or launches; so does a close the browser forces. + * The second click of a double click, a held key and a pointer press in the first moments after it + * opens answer nothing. Closing gives focus back to what had it before, as a modal dialog does. + */ +export function DecisionDialog({ decision, onChoose }: { decision: Decision | null; onChoose: (choice: DecisionChoice, detail: number) => void }) { + const id = useId(); + const dialog = useRef(null); + const heading = useRef(null); + const openedAt = useRef(0); + // Closes the dialog makes itself, because the question was answered or went: each fires a close event later, which is not a + // way out the browser forced. + const closes = useRef(0); + // The question as it stands when an event arrives. + const current = useRef(decision); + current.current = decision; + const asked = decision === null ? null : JSON.stringify([decision.kind, decision.title]); + useLayoutEffect(() => { + const node = dialog.current; + if (!node) return; + if (asked === null) { + if (node.open) { closes.current += 1; node.close(); } + return; + } + if (!node.open) node.showModal(); + // A new question, in a dialog just opened or one that stayed open, settles and takes focus again. + openedAt.current = performance.now(); + heading.current?.focus({ preventScroll: true }); + }, [asked]); + useLayoutEffect(() => () => { if (dialog.current?.open) { closes.current += 1; dialog.current.close(); } }, []); + + const wayOut = () => current.current?.choices.at(-1) ?? null; + /** A pointer press (`detail` ≥ 1) counts only as a single click after the dialog has settled; the keyboard's (0) always does. */ + function choose(choice: DecisionChoice | null, detail: number) { + if (!choice || detail > 1 || (detail === 1 && performance.now() - openedAt.current < DECISION_SETTLE_MS)) return; + onChoose(choice, detail); + } + const held = (event: KeyboardEvent) => { if (event.repeat && (event.key === "Enter" || event.key === " ")) event.preventDefault(); }; + return { event.preventDefault(); choose(wayOut(), 0); }} + onClose={() => { if (closes.current > 0) closes.current -= 1; else choose(wayOut(), 0); }} + onClick={event => { if (event.target === event.currentTarget) choose(wayOut(), event.detail); }}> + {decision &&
+

{decision.title}

+
+ {decision.text.map(text =>

{text}

)} + {decision.items.length > 0 &&
    {decision.items.map(item =>
  • {item}
  • )}
} +
+
+ {decision.choices.map((choice, index) => )} +
+
} +
; +} diff --git a/plugins/code/generator/decision-model.ts b/plugins/code/generator/decision-model.ts new file mode 100644 index 0000000..5fe9516 --- /dev/null +++ b/plugins/code/generator/decision-model.ts @@ -0,0 +1,84 @@ +import { orderedFamilies, providerPolicy } from "../../domain/providers.ts"; +import type { GateRefusal } from "./launch-step.ts"; +import { refusalView, type StatusAction, type StatusFacts, type Vocabulary } from "./statement-model.ts"; +import type { VerificationMode } from "./verification.ts"; + +/* + * WHAT A PRESS WAITS ON THE PERSON FOR, as one question: a model check's charge to approve, a + * reviewed session that differs from what the panel showed, a check that changed the profile, or a + * refused Launch and its fixes. Pure, so which choices each question offers, which comes first and + * which is the way out are tested apart from the dialog that asks it (decision-dialog.tsx). Only a + * press the person made asks: a check the panel runs on its own never does. + */ + +/** What a choice does: approve or cancel the waiting charge, take the launch's own press, leave the question, or run a fix. */ +export type DecisionDo = + | { readonly kind: "approve" } + | { readonly kind: "cancel" } + | { readonly kind: "launch" } + | { readonly kind: "dismiss" } + | { readonly kind: "fix"; readonly action: Extract }; +export type DecisionChoice = { readonly label: string; readonly does: DecisionDo }; +export type Decision = { + readonly kind: "charge" | "differs" | "narrowed" | "refused"; + readonly title: string; + /** What is asked and why, a plain sentence each. */ + readonly text: readonly string[]; + /** What the question is about, one fact per line: what a charge spends, each setting that moves. */ + readonly items: readonly string[]; + /** The choices in reading order. The first is the next action; the last is the way out, which Esc and the backdrop take, and which never spends or launches. */ + readonly choices: readonly DecisionChoice[]; +}; + +const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? "" : "s"}`; + +/** + * A model check's charge, waiting on the person: what it spends in all and per provider family (in + * the panel's family order: GPT, Claude, DeepSeek), why a launch needs it, and whether approving it + * once lets later checks run without asking (only for a person who keeps checks). A charge of nothing + * asks nothing: it says so, offers the accounts, and its way out cancels the check. Every choice but + * Approve cancels the check before it acts. + */ +export function chargeDecision(charge: { readonly requests: number; readonly providers: readonly { readonly provider: string; readonly requests: number }[] }, + mode: Exclude, keepsChecks: boolean, vocab: Vocabulary): Decision { + if (charge.requests === 0) { + return { kind: "charge", title: "Nothing to check", text: ["None of the models your included accounts offer can be checked."], items: [], + choices: [{ label: "Show accounts", does: { kind: "fix", action: { kind: "fix", key: "accounts", label: "Show accounts", fix: { kind: "open", place: "accounts" } } } }, + { label: "Close", does: { kind: "cancel" } }] }; + } + const spent = new Map(); + for (const entry of charge.providers) { + const family = providerPolicy(entry.provider).family; + if (entry.requests > 0) spent.set(family, (spent.get(family) ?? 0) + entry.requests); + } + const spend = orderedFamilies(spent.keys()).map(family => `${vocab.family(family)} ${spent.get(family)}`).join(" · "); + return { + kind: "charge", + title: mode === "launch" ? "Check your models first" : "Check your models", + text: [ + `${mode === "launch" ? "Launch needs to know" : "Code finds out"} which models your accounts can use${mode === "launch" ? ", so Code sends" : " by sending"} each one a tiny request.`, + ...keepsChecks ? ["You approve this once. Later checks run on their own."] : [], + ], + items: [`${plural(charge.requests, "tiny request")}${spend ? `: ${spend}` : ""}`], + choices: [{ label: mode === "launch" ? "Approve and launch" : "Approve", does: { kind: "approve" } }, { label: "Cancel", does: { kind: "cancel" } }], + }; +} + +/** A Launch press whose session review differs from what the panel showed: what differs, and both ways on. */ +export function differsDecision(differs: readonly string[]): Decision { + return { kind: "differs", title: "What will run differs", text: ["The session Code is about to start is not quite what the panel shows."], items: differs, + choices: [{ label: "Launch it", does: { kind: "launch" } }, { label: "Not now", does: { kind: "dismiss" } }] }; +} + +/** A Launch press whose model check narrowed the profile to what answered: the settings it moved, and both ways on. */ +export function narrowedDecision(settings: readonly string[]): Decision { + return { kind: "narrowed", title: "The model check changed your profile", text: ["Some models didn't answer, so the check changed these settings."], items: settings, + choices: [{ label: "Launch", does: { kind: "launch" } }, { label: "Not now", does: { kind: "dismiss" } }] }; +} + +/** A refused Launch press: what stops it, why, the fixes the launch line offers for it, and Close. */ +export function refusalDecision(refusal: GateRefusal, facts: StatusFacts, vocab: Vocabulary): Decision { + const view = refusalView(refusal, facts, vocab); + return { kind: "refused", title: view.title, text: view.why ? [view.why] : [], items: [], + choices: [...view.fixes.map(action => ({ label: action.label, does: { kind: "fix", action } }) as const), { label: "Close", does: { kind: "dismiss" } }] }; +} diff --git a/plugins/code/generator/generator-pane.tsx b/plugins/code/generator/generator-pane.tsx index e37fbdd..dfc1497 100644 --- a/plugins/code/generator/generator-pane.tsx +++ b/plugins/code/generator/generator-pane.tsx @@ -1,5 +1,7 @@ import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react"; +import { createPortal } from "react-dom"; import type { MachineSummary } from "@manifold/protocol"; +import { useNotice } from "@manifold/plugin/hooks"; import type { OmpPresence } from "../machine-web.ts"; import { prefersReducedMotion } from "@manifold/ui"; import type { CompiledCatalog } from "../../domain/catalog.ts"; @@ -12,7 +14,9 @@ import { displayAliases } from "./aliases.ts"; import type { ListFailure } from "./board-model.ts"; import { rescue } from "./consequences.ts"; import { previewSelection } from "./dial-space.ts"; -import { listServes } from "./launch-step.ts"; +import { DecisionDialog } from "./decision-dialog.tsx"; +import { chargeDecision, differsDecision, narrowedDecision, refusalDecision, type Decision, type DecisionChoice } from "./decision-model.ts"; +import { listServes, type GateRefusalCode } from "./launch-step.ts"; import type { VerificationFailure } from "./model-verification.ts"; import { DialRow, Glyph, toneColor } from "./dial-row.tsx"; import { MachinePicker, type MachinePickerHandle } from "./machine-picker.tsx"; @@ -24,13 +28,15 @@ import { generatorRows, ROW_EXTRAS, type GeneratorRow, type RowId, type RowWord import { changeSentence, commitKind, estimateReadouts, fixView, launchLine, launchReadout, laneFix, poolCounts, quotaParts, reviewDifferences, machineSlot, sameTeam, standstill, statementSlots, teamEdits, verbView, WORD_NAMES, - type EstimateReadout, type SlotOption, type StatementContext, type StatusAction, type StatusFix, type StatusLine, type Vocabulary, + type EstimateReadout, type SlotOption, type StatementContext, type StatusAction, type StatusFacts, type StatusFix, type StatusLine, type Vocabulary, } from "./statement-model.ts"; import type { WorkbenchModel } from "./workbench-model.ts"; /** Generator-panel class prefix; every part hangs from the generator root (styles.css). */ const G = "plugin-atyrode_code_generator__"; const HOUR = 3_600_000; +/** The one notice slot a press's outcome takes in Manifold's notice stack: each outcome replaces the last where it stands. */ +const PRESS_NOTICE = "atyrode.code.press"; /** How long the readout keeps what a key or a press did before it gives way to what is pointed. */ const KEY_SAID_MS = 2600; /** How long the readout keeps a scrub's consequence after the pointer lets go. */ @@ -112,6 +118,8 @@ export type GeneratorPaneProps = { /** Hears the profile switches kept outside the rows (the session options sheet draws them) whenever they change. */ readonly onExtras: (extras: readonly SlotOption[]) => void; readonly controls: RefObject; + /** Where the dialog a press waits on mounts: outside the stage and the sheets, so it shows whichever of them is shown. */ + readonly decisionHost: HTMLElement | null; }; /** What the readout says: a value in bold, then what it means or does, warm when it refuses or strains. */ @@ -134,7 +142,7 @@ type Scrub = { readonly row: RowId; readonly origin: Review | null; readonly cat * 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; + const { model, pools, machines, presence, selectMachine, recents, connected, ledger, shown, profile, hidden, listFailure, reveal, announce, onOpen, onRefresh, onLaunchState, onExtras, controls, decisionHost } = props; const id = useId(); const root = useRef(null); const launchButton = useRef(null); @@ -147,6 +155,9 @@ export function GeneratorPane(props: GeneratorPaneProps) { // A Launch press whose check narrowed the profile to what answered stopped before launching: the saved profile it stopped // on (`profileKey`) and the settings the check moved. const [narrowed, setNarrowed] = useState<{ readonly on: string; readonly differs: readonly string[] } | null>(null); + // A refused Launch press asks its question until it is answered or the refusal changes: the refusal it was pressed on. + const [refused, setRefused] = useState(null); + const { notify } = useNotice(); // A Launch press that checks the models first: the profile it was pressed on, and the last check failure and message before it. const chain = useRef<{ readonly wanted: Selection | null; readonly failure: VerificationFailure | null; readonly message: unknown } | null>(null); const keyTimer = useRef(0), scrubTimer = useRef(0); @@ -211,20 +222,31 @@ export function GeneratorPane(props: GeneratorPaneProps) { const profileKey = JSON.stringify([model.record?.revision ?? null, model.record?.selection ?? null, model.localDraft !== null, model.machineId]); const shownNarrowed = narrowed !== null && narrowed.on === profileKey ? narrowed.differs : null; useEffect(() => { if (narrowed !== null && narrowed.on !== profileKey) setNarrowed(null); }); + // A refused press's question goes with its refusal: a refusal that lifted or changed is not asked again without a press. + const stillRefused = refused !== null && verb.state === "refused" && verb.refusal?.code === refused; + useEffect(() => { if (refused !== null && !stillRefused) setRefused(null); }); // What a held review shows unlike what the panel showed, in the statement's words, while it stands. const differs = useMemo(() => model.held && catalog ? reviewDifferences(catalog, model.held.projected, model.held.providers, model.held.review.composition, vocab) : null, [model.held, catalog, vocab]); - const line = launchLine({ - verb, phase: verification.phase, mode: verification.mode, progress, charge, keepsChecks: verification.keepsChecks !== false, inFlight: model.inFlight, + const status: StatusFacts = { + verb, phase: verification.phase, mode: verification.mode, progress, charge, inFlight: model.inFlight, busy: model.busy, chaining: model.chaining, launching, machine: machine && { name: machine.name, online: machine.online, revoked: machine.revoked === true }, machineChosen: model.machineId !== "", rosterUnread: rosterError, otherMachine: other && { id: other.id, name: other.name }, message: model.message ?? stoppedVerifying, outcome: model.outcome, stop: quota?.stop ?? null, fix: quota?.fix ?? null, - differs, narrowed: shownNarrowed, laneFix: verb.refusal?.code === "no-account" && slots && selection ? laneFix(slots.lane, selection) : null, nobodyServes: served !== null && served.size === 0, listFailure, ompMissing: model.ompMissing || presence?.get(model.machineId) === "absent", accountsProblem: model.accountsProblem, configurationFailed: model.configurationFailed, - }, vocab); + }; + const line = launchLine(status, vocab); + // The one question a press waits on, in precedence: a check's charge (from the launch or Models), a review that differs, a + // check that changed the profile, then a refused press. A check the panel runs on its own never asks. + const decision: Decision | null = charge && verification.mode !== null && verification.mode !== "background" + ? chargeDecision(charge, verification.mode, verification.keepsChecks !== false, vocab) + : differs && verb.state === "ready" ? differsDecision(differs) + : shownNarrowed && verb.state === "ready" ? narrowedDecision(shownNarrowed) + : stillRefused && verb.refusal ? refusalDecision(verb.refusal, status, vocab) + : null; const reviewed = model.launchReview && catalog ? { machine: machine?.name ?? model.launchReview.destination.machineId, pool: poolCounts(catalog, model.launchReview.composition.review.routes, model.launchReview.composition.accountPool), @@ -392,11 +414,15 @@ export function GeneratorPane(props: GeneratorPaneProps) { if (verb.checks) { chain.current = { wanted: selection, failure: verification.failure, message: model.message }; setLaunching(true); } model.actions.next(); } - /** The press: a refused one shakes and says why; one that runs plays its charge and check, and the team lights up. */ + /** + * The press: a refused one asks its question (the reason, why, and its fixes); one pressed while a step runs shakes and says + * why; one that runs plays its charge and check, and the team lights up. + */ function fire() { const button = launchButton.current; if (verb.opens) { onOpen(verb.opens); return; } const still = prefersReducedMotion(); + if (verb.state === "refused" && verb.refusal) { setRefused(verb.refusal.code); return; } if (verb.state !== "ready") { if (!still) button?.animate([{ transform: "none" }, { transform: "translateX(-2px)" }, { transform: "translateX(2px)" }, { transform: "none" }], { duration: 260 }); say({ value: verb.label.toLowerCase(), text: launchSays.text, warn: true, color: null, staged }); @@ -420,7 +446,7 @@ export function GeneratorPane(props: GeneratorPaneProps) { window.setTimeout(() => { delete button.dataset.pressing; fire(); }, 160); } // A Launch that checks first goes on to the launch once its check is done, unless the check narrowed the profile to what - // answered, which is said for the person to choose. A check that stopped, or a failed or unread workspace profile, ends the + // answered, which is asked for the person to choose. A check that stopped, or a failed or unread workspace profile, ends the // press and never retries it. `next` asks the gate again, and a launch the gate refuses then is said by the verb. useLayoutEffect(() => { const pressed = chain.current; @@ -434,14 +460,24 @@ export function GeneratorPane(props: GeneratorPaneProps) { if (model.step.step !== "launch") return; const moved = pressed.wanted && model.record?.selection ? teamEdits(pressed.wanted, model.record.selection, familyWord) : []; if (moved.length === 0) { model.actions.next(); return; } - const settings = moved.map(edit => `${WORD_NAMES[edit.word].toLowerCase()} ${edit.from} → ${edit.to}`); - setNarrowed({ on: profileKey, differs: settings }); - announce(`The model check changed the profile: ${settings.join(", ")}. Launch to go on with it, or change a setting first.`); + setNarrowed({ on: profileKey, differs: moved.map(edit => `${WORD_NAMES[edit.word].toLowerCase()} ${edit.from} → ${edit.to}`) }); }); - // A press that stopped on a review unlike what the panel showed says what differs, once. - useEffect(() => { - if (differs) announce(`What will run differs: ${differs.join("; ")}. Launch again to run it, or change a setting first.`); - }, [differs]); + /** + * An answer to the question a press waits on. Every way out of a check's charge but Approve cancels the check first, so + * nothing is spent; Approve spends only the charge the dialog showed (`confirmsCharge`). Not now drops a held review, so + * the next press reviews again, and a narrowed stop or a refused press's question simply ends. + */ + function decide(choice: DecisionChoice, detail: number) { + const does = choice.does; + if (decision?.kind === "charge") { + if (does.kind === "approve") { if (charge) model.actions.confirmCharge({ detail, repeat: false }, charge); return; } + verification.cancel(); + } else if (decision?.kind === "differs" && does.kind === "dismiss") model.actions.dropHeld(); + else if (decision?.kind === "narrowed") setNarrowed(null); + else if (decision?.kind === "refused") setRefused(null); + if (does.kind === "launch") fire(); + else if (does.kind === "fix") runFix(does.action); + } // What the panel's keys ask of the pane, current on every render. const latest = { launch: pressFromKeys, defaults: () => resetTo("defaults"), saved: () => resetTo("saved"), recall, machines: () => picker.current?.open(), @@ -488,18 +524,20 @@ export function GeneratorPane(props: GeneratorPaneProps) { return () => { live = false; observer.disconnect(); }; }, [rows !== null]); - // ------------------------------------------------------------ announcements: the charge, outcomes and failures, once each - const seen = useRef({ outcome: model.outcome, message: model.message, charge, failure: verification.failure }); + // ------------------------------------------------------------ notices: how a press ended, once each + // Said in Manifold's one notice stack as well as beside the launch, so it is seen wherever the person is looking; the stack + // announces it too. A cancelled check is the person's own doing and a check the panel ran on its own held up nothing, so + // neither is said there. + const seen = useRef({ outcome: model.outcome, message: model.message, failure: verification.failure }); useEffect(() => { const last = seen.current; - if (model.outcome && model.outcome !== last.outcome) announce(`${model.outcome.kind === "launched" ? "Launched" : "Resumed"} on ${model.outcome.machine}`); - else if (model.message?.failed && model.message !== last.message) announce(model.message.text); - else if (stoppedVerifying && verification.failure !== last.failure) announce(stoppedVerifying.text); - if (charge && charge !== last.charge) { - announce(charge.requests > 0 ? `Checking models spends ${charge.requests} tiny requests. Approve is the next control.` : "Nothing to check through these accounts."); - } - seen.current = { outcome: model.outcome, message: model.message, charge, failure: verification.failure }; - }, [model.outcome, model.message, charge, verification.failure]); + seen.current = { outcome: model.outcome, message: model.message, failure: verification.failure }; + const outcome = model.outcome && model.outcome !== last.outcome ? model.outcome : null; + const notice = outcome ? outcome.kind === "launched" ? `Launched on ${outcome.machine}. Type your task in its terminal.` : `Resumed on ${outcome.machine}.` + : model.message && model.message !== last.message ? model.message.text + : stoppedVerifying?.failed && verification.failure !== last.failure ? stoppedVerifying.text : null; + if (notice) notify(notice, { key: PRESS_NOTICE }); + }, [model.outcome, model.message, verification.failure]); // ------------------------------------------------------------ render const lineId = `${id}-line`; @@ -553,12 +591,12 @@ export function GeneratorPane(props: GeneratorPaneProps) { { lineFocus.current = event.target; }} onBlur={event => { if (event.relatedTarget) lineFocus.current = null; }}> - {line ? charge && model.actions.confirmCharge({ detail, repeat: false }, charge)} - onCancel={verification.cancel} onFix={runFix} /> : null} + {line ? : null} + {decisionHost && createPortal(, decisionHost)} ; } @@ -588,27 +626,20 @@ function Meter({ name, glyph, readout, onPoint }: { name: string; glyph: string; } /** - * The line beside the launch and its machine: Approve first when it carries a check's charge, then - * its facts, then its fixes and cancel as cues; a fix that opens a place answers to that place's key - * too. Its asides are the readout's, said while the launch is pointed. Approve is the next Tab stop - * after the launch: while a charge waits the machine cannot change, and its word leaves the Tab order. + * The line beside the launch and its machine: its facts, then its fixes and a running check's cancel + * as cues; a fix that opens a place answers to that place's key too. Its asides are the readout's, + * said while the launch is pointed. What a press waits on the person for is asked in its dialog. */ -function LaunchLine({ line, canConfirm, onConfirm, onCancel, onFix }: { - line: StatusLine; canConfirm: boolean; onConfirm: (detail: number) => void; onCancel: () => void; onFix: (action: Extract) => void; +function LaunchLine({ line, onCancel, onFix }: { + line: StatusLine; onCancel: () => void; onFix: (action: Extract) => void; }) { const parts = line.parts.filter(entry => entry.tone !== "meta"); - const confirm = line.actions.find(action => action.kind === "confirm"); return <> - {confirm && } {parts.length > 0 && {parts.map((entry, index) => {entry.text})} } {line.actions.map(action => { if (action.kind === "cancel") return ; - if (action.kind !== "fix") return null; const key = action.fix.kind === "open" ? FIX_KEYS[action.fix.place] : action.fix.kind === "refresh" ? FIX_KEYS.refresh : undefined; return ; })} diff --git a/plugins/code/generator/machine-picker.tsx b/plugins/code/generator/machine-picker.tsx index 4b8287c..13e30a7 100644 --- a/plugins/code/generator/machine-picker.tsx +++ b/plugins/code/generator/machine-picker.tsx @@ -29,8 +29,8 @@ export type MachinePickerProps = { * or ↑↓ and ↵, chooses; Esc, Tab or a press outside closes, and focus returns to the word. While the * list is open it owns its keys (`data-popover`), so the panel's keys leave them alone. A machine * that cannot be chosen is struck through and refuses with its reason. While the destination cannot - * change at all (a step runs or a charge waits), the word refuses with the gate's reason on a press - * or `w`, and leaves the Tab order, so Confirm charge stays the next stop after the launch. + * change at all (a step runs or a check's charge waits), the word refuses with the gate's reason on + * a press or `w`, as a locked row does. */ export function MachinePicker({ options, locked, onChoose, onRefuse, onLocked, onPoint, handle }: MachinePickerProps) { const id = useId(); @@ -91,7 +91,7 @@ export function MachinePicker({ options, locked, onChoose, onRefuse, onLocked, o return