diff --git a/CHANGELOG.md b/CHANGELOG.md index cf9e07db..1772d5aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,82 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.0] - 2026-10-01 + +Phones and tablets get more of the screen for reading, and on/off options in +the editor are switches labelled by what they control. Desktop windows 1024 px +or wider with a mouse keep the 0.5.0 layout, with text at 12 px or more. Anyone editing node properties, +publishing, starting a run or using the Resources panel sees the switches. +Nothing changes in VisualFlow documents, the server, or the Gateway contract. + +### Added +- **Collapsible lists.** In the flow library and the run window, the list + above the detail has a header you tap to hide or show it (Flows, + Execution), with a mouse, a tap, or Enter and Space. Lists start open. The + editor remembers your choice in this browser. Hiding the list gives the + preview or the step details the whole sheet. + +### Changed +- **Plain names and descriptions for the shipped workflows.** Every shipped + entrypoint has a name a person would use ("Basic agent", "Coding agent + (chat)", "Deep research") and one sentence of at most 140 characters that + says what it takes and what it returns, instead of its id and a developer + note. The text lives in one table, `scripts/workflow_labels.py`; the + generators take it from there, and `scripts/relabel_shipped_bundles.py` + writes it into the latest shipped bundle of each family and the example + JSONs (`apply`) and checks that generators, examples and bundles agree + (`check`). Only the labels change: graphs, versions and older versions stay + as they were, except docs-qa, which the gateway keeps in its catalog and + therefore ships the new text as 0.1.2. +- **One scroll per sheet.** On phones and tablets, the flow library, the run + window and the run history scroll as one page. Lists no longer scroll inside + their own box, and sections are flat, with a thin line between items. +- **Full width on phones.** The run window's text uses the width of the + screen: a 12 px margin, label and value on one line when they fit, JSON and + paths without a box. The assistant's replies are plain text across the + drawer. The Assistant, Properties and Functions tabs sit in a row at the + top, so the canvas and the open panel lose no side column. In the flow + library, a flow's name starts at the margin and the expand arrow sits at + the end of its row. +- **Larger text on touch screens.** Phones and tablets use a larger type + scale: body text 14 to 15 px, secondary text 13 px. Your font-size setting + still applies. Text inside canvas nodes keeps its size and grows when you + zoom. +- **No text under 12 px on desktops.** Dense text (badges, keys, list + details) is at least 12 px and secondary text 13 px, so the palette, the + properties panel and the run history read at 12 to 13 px instead of 11 px. + Some palette names are shortened one character sooner. +- Dialogs opened from the toolbar are no longer inside the page header, so + screen readers no longer read them as part of the header. + +### Fixed +- On phones, a notice raised while a dialog is open appears below the + dialog's title bar. It used to cover the run window's close button, and a + tap copied the error instead of closing the window. +- **Switches show their state.** A switch is highlighted with a check mark + when on, plain when off. +- **Node settings.** **Structured output** (Agent), **Recurrent** (On + Schedule) and **Free text answers** (Ask User) are switches in the + properties panel. Recurrent no longer shows an "Enabled" / "Disabled" label. +- **Dialog options.** **Reload gateway bundles** (Publish), **New folder per + run** (the run window's workspace folder), **Durable** (sending an event to + a waiting run), **Lock after load** and **Cached and non-resident models** + (Resources) are switches. In the run window, New folder per run shows why it + cannot change while the gateway manages the workspace or a run is in + progress. +- Pause and Resume on a running run stay buttons: they act on the run once, + they are not a setting. +- **Plain http from another machine** (LAN, Tailscale). Browsers withhold + `crypto.randomUUID`, the clipboard API and the microphone outside https and + localhost. Request, session and switch-case ids now come from the kit's + `randomId()` (v4 UUIDs everywhere), every **Copy** falls back to a + text-selection copy and says "Copied" or "Copy failed — select and copy", + and a voice wait over plain http disables **Record** and says why: "This + page is loaded over http, so voice and camera is unavailable — open it over + https (for example through tailscale serve; the gateway console's Network + page explains how) or on the gateway's own computer." Needs + `@abstractframework/ui-kit` 0.4.0. + ## [0.5.0] - 2026-09-30 The editor works on phones, tablets, and browser windows of any size. On a diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 70fc58df..56fbf495 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ Requirements: - Node.js 20+ (the CI uses Node.js 24) - npm -- a sibling [AbstractUIC](https://github.com/lpalbou/AbstractUIC) checkout at `../abstractuic`: the editor builds the shared UI packages (`@abstractframework/ui-kit` and the monitor widgets) from it +- a sibling [AbstractUIC](https://github.com/lpalbou/AbstractUIC) checkout at `../abstractuic`: the editor builds the monitor widgets from it (`@abstractframework/ui-kit` installs from npm) - a reachable AbstractGateway for integration testing ```bash diff --git a/README.md b/README.md index dd387ed9..0c5335a3 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,9 @@ npx @abstractframework/flow --gateway-url http://127.0.0.1:8080 ``` For a local checkout, clone [AbstractUIC](https://github.com/lpalbou/AbstractUIC) -next to this repository (`../abstractuic`): the editor builds the shared UI -packages (`@abstractframework/ui-kit` and the monitor widgets) from that sibling -checkout. +next to this repository (`../abstractuic`): the editor builds the monitor +widgets from that sibling checkout. `@abstractframework/ui-kit` installs from +npm with `npm install`. ```bash git clone https://github.com/lpalbou/AbstractUIC.git ../abstractuic diff --git a/docs/README.md b/docs/README.md index ec24ecea..05650578 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,7 +41,7 @@ The canvas reflects that split: Start here: - [Getting started](getting-started.md): start a Gateway, run the editor, sign in, and author a first workflow. -- [Web editor](web-editor.md): the editor's features: sign-in, About, the responsive layout on phones and tablets, discovery, Resources, flow interfaces, automation defaults and trigger-source discovery, hidden connections, the authoring assistant, media and file nodes. +- [Web editor](web-editor.md): the editor's features: sign-in, About, the responsive layout on phones and tablets, switches, discovery, Resources, flow interfaces, automation defaults and trigger-source discovery, hidden connections, the authoring assistant, media and file nodes. - [Troubleshooting](troubleshooting.md): symptoms, causes, and fixes for sign-in, proxy, editor, and local development problems. - [FAQ](faq.md): short answers about what Flow owns and what it delegates. diff --git a/docs/getting-started.md b/docs/getting-started.md index c2433321..d2d43bca 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -50,7 +50,7 @@ for compatible models only; Flow itself does not impose that default. ## Local Development -A local checkout needs Node.js 20+ and a sibling [AbstractUIC](https://github.com/lpalbou/AbstractUIC) checkout: the editor builds the shared UI packages (`@abstractframework/ui-kit` and the monitor widgets) from `../abstractuic`. +A local checkout needs Node.js 20+ and a sibling [AbstractUIC](https://github.com/lpalbou/AbstractUIC) checkout: the editor builds the monitor widgets from `../abstractuic`. `@abstractframework/ui-kit` installs from npm with `npm install`. ```bash git clone https://github.com/lpalbou/AbstractUIC.git abstractuic diff --git a/docs/shipped-workflow-sources.md b/docs/shipped-workflow-sources.md index d5d46240..2992aede 100644 --- a/docs/shipped-workflow-sources.md +++ b/docs/shipped-workflow-sources.md @@ -29,6 +29,21 @@ Bundle versions are immutable by sha, so publishing a changed graph means bumping the version in the generator and updating the packaging pin in `abstractgateway/pyproject.toml`. +The name and one-line description each entrypoint shows on the Gateway's +Workflows page come from one table, `scripts/workflow_labels.py`. The +generators read it, and `scripts/relabel_shipped_bundles.py` keeps the shipped +bundles in line without repacking their graphs: + +```bash +python3 scripts/relabel_shipped_bundles.py check --bundles-dir ../abstractgateway/flows/bundles +python3 scripts/relabel_shipped_bundles.py apply --bundles-dir ../abstractgateway/flows/bundles +``` + +`check` fails when a generator, an example JSON or the latest shipped bundle +of a family carries other text, or when a shipped bundle has an entrypoint the +table does not name. `apply` rewrites only the labels of the latest version of +each family; older versions are never touched. + None of these shipped flows cuts what a model reads (ADR-0026): no message or character caps on prompts, tool output or documents, and no default output-token caps. The only bounds left are labeled display previews (for example, a diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 53278cc8..8f7ac7a3 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -161,12 +161,27 @@ it through the interface receives missing or mistyped values. editor at build time. - **Fix:** run `npm run build` again and reload the editor tab. +### Voice is unavailable, or Copy says "Copy failed — select and copy" + +- **Cause:** the editor is open over plain `http://` from another machine (a + LAN or Tailscale address such as `http://100.x.y.z:8080/apps/flow/`). + Browsers offer the microphone, the camera and the clipboard only on https + pages or on `localhost`. Over plain http, a voice wait in the run window + disables **Record** and says why, and **Copy** falls back to a + text-selection copy that the browser may refuse. +- **Fix:** open the editor over https, or on the gateway's own computer. With + Tailscale, run `tailscale serve --bg http://127.0.0.1:` on the gateway + machine and open `https://..ts.net/apps/flow/`; the gateway + console's Network page explains the steps. Everything else in the editor + works over plain http. + ## Local Development ### The build cannot resolve `@abstractframework/ui-kit` or a monitor package -- **Cause:** a local checkout builds the shared UI packages from a sibling - AbstractUIC checkout at `../abstractuic`. +- **Cause:** `@abstractframework/ui-kit` installs from npm, so `npm install` + has not run yet; or the monitor widgets, which a local checkout builds from + a sibling AbstractUIC checkout at `../abstractuic`, are missing. - **Fix:** ```bash diff --git a/docs/web-editor.md b/docs/web-editor.md index b7ab7060..3d547ed0 100644 --- a/docs/web-editor.md +++ b/docs/web-editor.md @@ -70,24 +70,49 @@ the window width: touch screens. The minimap starts collapsed on phones and short screens; use the toggle in the bottom-right corner to show it. The view fits the flow again when you rotate the device. -- **Flow library and run window on phones.** The library shows the flow list - above the preview (side by side in landscape). The run window shows the - execution steps above the details. Both scroll inside the sheet. +- **Flow library and run window on phones and tablets.** Below 1024 px the + library shows the flow list above the preview (side by side in phone + landscape), and the run window shows the execution steps above the step + details. Each sheet scrolls as one page; the lists do not scroll inside + their own box. Sections are flat, with a thin line between items. The + list's header (**Flows**, **Execution**) hides or shows the list with a + tap, a click, Enter or Space; lists start open and the editor remembers + your choice in this browser. On phones the run window uses the width of + the screen with a 12 px margin, and label and value share a line when they + fit. +- **Side panel tabs on phones.** The Assistant, Properties and Functions tabs + sit in a row at the top of the drawer, so the open panel uses the full + width. - **On-screen keyboard.** The assistant composer and the sheets' action rows, such as the library's Load button, stay above the keyboard. In phone landscape a 200 px keyboard leaves room for the composer; a taller keyboard leaves less space than the composer needs. - **Touch targets and text.** Toolbar buttons, palette nodes, library rows, - selects and dialog buttons are at least 44 px on touch screens. Inputs use - 16 px text so iOS does not zoom in when you focus them, and the properties - panel's help text is 14 px. + selects and dialog buttons are at least 44 px on touch screens. Text on + touch screens uses a larger scale: body text 14 to 15 px, secondary text + 13 px. Inputs use 16 px text so iOS does not zoom in when you focus them. + Your font-size setting multiplies every size. - **Notifications** appear at the top of the screen on phones, clear of the sheets' action rows, and at the bottom right elsewhere. What stays desktop-sized: node cards and the controls inside them (pin inputs, pin selects, "Edit Code") keep their compact design and scale with -the canvas zoom, so zoom in to work on them on a small screen. The desktop -panels keep their dense 11-13 px text on a mouse-driven screen. +the canvas zoom, so zoom in to work on them on a small screen. On a desktop +with a mouse, the panels keep their dense layout with text at 12 px or more +(secondary text 13 px). + +## Switches + +On/off settings are switches labelled by what they control; a switch is +highlighted with a check mark when on and plain when off. The properties +panel has **Structured output** (Agent), **Recurrent** (On Schedule) and +**Free text answers** (Ask User). The dialogs have **Reload gateway bundles** +(Publish), **New folder per run** (the run window's workspace folder), +**Durable** (sending an event to a waiting run), and **Lock after load** and +**Cached and non-resident models** (Resources). When a switch cannot change, +it says why next to it: **New folder per run** is fixed while the gateway +manages the workspace or a run is in progress. Pause and Resume on a running +run are buttons, because they act on the run once. ## Provider And Model Discovery @@ -118,9 +143,9 @@ three tabs: length (✓ marks a calibrated value). Locked models carry a lock marker; Lock/Unlock and a per-model context Estimate appear when Gateway advertises those endpoints. Unloading a locked model asks for an explicit Force Unload - confirmation. A "Show cached/non-resident" toggle reveals cached and - configuration-only rows, and the load bar can lock a model as part of - loading it. + confirmation. The **Cached and non-resident models** switch reveals cached + and configuration-only rows, and the load bar's **Lock after load** switch + locks a model as part of loading it. - **Memory** shows host RAM, device, and per-GPU meters, process RSS, and degraded-state reasons from Gateway host state, refreshed every 5 seconds while the tab is open. diff --git a/examples/flows/81795ea9.json b/examples/flows/81795ea9.json index fef97be0..1e8ed3bd 100644 --- a/examples/flows/81795ea9.json +++ b/examples/flows/81795ea9.json @@ -1,7 +1,7 @@ { "id": "81795ea9", - "name": "basic-agent", - "description": "The framework default agent: one Agent node with tools, memory, and status updates via the ac-update-status helper. Runnable by chat hosts (AbstractCode, entity phases, gateway agency loops) through the abstractcode.agent.v1 contract.", + "name": "Basic agent", + "description": "Chat agent: answers a prompt in one agent loop that can use tools and memory, and returns the reply.", "interfaces": [ "abstractcode.agent.v1" ], diff --git a/examples/flows/adversarial-review.json b/examples/flows/adversarial-review.json index a36872d6..49a95a12 100644 --- a/examples/flows/adversarial-review.json +++ b/examples/flows/adversarial-review.json @@ -1,7 +1,7 @@ { "id": "adversarial-review", - "name": "adversarial-review", - "description": "Reusable review primitive: three independent critics review an artifact through correctness / design-robustness / requirements-fit lenses, then a merge step consolidates and severity-ranks the findings into one pass/revise/block verdict. Composable as a subflow.", + "name": "Adversarial review", + "description": "Three critics review text or code for correctness, design and fit to requirements; returns ranked findings and a pass/revise/block verdict.", "interfaces": [ "abstractreview.adversarial.v1" ], diff --git a/examples/flows/co-scientist.json b/examples/flows/co-scientist.json index 44ec667a..d1a9f1ce 100644 --- a/examples/flows/co-scientist.json +++ b/examples/flows/co-scientist.json @@ -1,7 +1,7 @@ { "id": "co-scientist", - "name": "co-scientist", - "description": "Deep multi-agent hypothesis engine (Nature 'AI co-scientist' replica). Starts FROM the literature by delegating grounding to the deep-research investigation engine (deep-plan + deep-investigate: real web search + a verified source ledger), then runs a supervisor loop of Generation -> Reflection -> Elo-ranking (prioritized pairwise scientific debate) -> Evolution -> research-expansion, threading meta-feedback forward each cycle with near-duplicate pruning, then a final search-grounded full review of the finalists and a Meta-review research overview. Deliberately deeper than a single-pass report - it scales test-time compute via the cycle budget.", + "name": "Co-scientist", + "description": "Takes a research goal, grounds it in web sources, then generates, debates and ranks hypotheses over cycles; returns a research overview.", "interfaces": [ "abstractresearch.coscientist.v1" ], diff --git a/examples/flows/coder.json b/examples/flows/coder.json index 0f7f66d1..abea550a 100644 --- a/examples/flows/coder.json +++ b/examples/flows/coder.json @@ -1,7 +1,7 @@ { "id": "coder", - "name": "coder", - "description": "Chat-agent entrypoint for the coding-agent pipeline: takes a plain prompt (abstractcode.agent.v1), runs the verify-gated build loop in the session workspace with inferred gates (no explicit build/run commands — the verifier executes what it can and reports honestly), and returns the build report as the response.", + "name": "Coding agent (chat)", + "description": "Chat version of the coding agent: builds what the prompt asks in the session workspace, checks it each round, and replies with a report.", "interfaces": [ "abstractcode.agent.v1" ], diff --git a/examples/flows/coding-agent.json b/examples/flows/coding-agent.json index 9c763cdf..4f22e3c8 100644 --- a/examples/flows/coding-agent.json +++ b/examples/flows/coding-agent.json @@ -1,7 +1,7 @@ { "id": "coding-agent", - "name": "coding-agent", - "description": "Advanced coding agent with recursive verification: a builder agent writes code, an independent verifier runs build/execute/match gates each round, and failures are fed back as specific reprompts until all gates pass or the round budget is spent.", + "name": "Coding agent", + "description": "Takes a build request and a workspace, writes the code, and runs build and run checks each round until they pass; returns a report.", "interfaces": [ "abstractcode.coding.v1" ], diff --git a/examples/flows/deep-research.json b/examples/flows/deep-research.json index dc5122f5..3ec886bf 100644 --- a/examples/flows/deep-research.json +++ b/examples/flows/deep-research.json @@ -1,7 +1,7 @@ { "id": "deep-research", - "name": "deep-research", - "description": "Production research workflow with adversarial review, provenance, and Markdown/PDF/DOCX export.", + "name": "Deep research", + "description": "Takes a research question, searches the web, has three critics review the draft, and writes a cited report (Markdown, PDF, DOCX).", "interfaces": [ "abstractcode.agent.v1", "abstractresearch.deep.v1" diff --git a/examples/flows/map-reduce.json b/examples/flows/map-reduce.json index ace376fb..8f1aa01b 100644 --- a/examples/flows/map-reduce.json +++ b/examples/flows/map-reduce.json @@ -1,7 +1,7 @@ { "id": "map-reduce", - "name": "map-reduce", - "description": "Reusable batch primitive: map a per-item LLM instruction over an input array (ForEach), accumulate the per-item results, then reduce them with a synthesis LLM instruction. Returns the per-item results and the synthesized output.", + "name": "Map-reduce", + "description": "Applies one LLM instruction to each item of a list, then combines the per-item results into one synthesis; returns both.", "interfaces": [ "abstractbatch.mapreduce.v1" ], diff --git a/examples/flows/meta-baseline.json b/examples/flows/meta-baseline.json index 2f4bfd72..77952f4a 100644 --- a/examples/flows/meta-baseline.json +++ b/examples/flows/meta-baseline.json @@ -1,7 +1,7 @@ { "id": "meta-baseline", - "name": "meta-baseline", - "description": "Control arm for the meta-intelligence benchmark: ONE isolated LLM call answering directly (same system prompt, same machinery, no orchestration). Conforms to abstractcode.agent.v1.", + "name": "Single call (baseline)", + "description": "Answers the prompt with one direct LLM call; the baseline the other deliberation workflows are compared against.", "interfaces": [ "abstractcode.agent.v1", "abstractmeta.intelligence.v1" diff --git a/examples/flows/meta-consensus.json b/examples/flows/meta-consensus.json index d1329fb4..4d35d896 100644 --- a/examples/flows/meta-consensus.json +++ b/examples/flows/meta-consensus.json @@ -1,7 +1,7 @@ { "id": "meta-consensus", - "name": "meta-consensus", - "description": "Meta-intelligence: two independent LLM answers reconciled into one consensus answer — agreement is kept as reliable, divergence is resolved by reasoning, not averaged. Diversity comes from temperature (0.3 vs 0.9) by default, or from a genuinely different second model via the optional provider_b/model_b inputs. Self-consistency pattern; comparable 1:1 against an isolated LLM call on the same prompt. Consensus across N>2 answerers needs a loop-based flow (future scope).", + "name": "Consensus", + "description": "Gets two independent answers (two temperatures or two models) and merges them into one, keeping agreements and resolving differences.", "interfaces": [ "abstractcode.agent.v1", "abstractmeta.intelligence.v1" diff --git a/examples/flows/meta-debate.json b/examples/flows/meta-debate.json index a9f87e27..6dc77260 100644 --- a/examples/flows/meta-debate.json +++ b/examples/flows/meta-debate.json @@ -1,7 +1,7 @@ { "id": "meta-debate", - "name": "meta-debate", - "description": "Meta-intelligence: proposer answers, an adversarial challenger attacks the answer (flaws, counterexamples, missing cases), then the proposer concedes-or-rebuts point by point and writes the defended final answer. Multi-agent debate pattern; comparable 1:1 against an isolated LLM call.", + "name": "Debate", + "description": "One model answers, a challenger attacks the answer, then the first model concedes or rebuts each point and writes the final answer.", "interfaces": [ "abstractcode.agent.v1", "abstractmeta.intelligence.v1" diff --git a/examples/flows/meta-deliberate.json b/examples/flows/meta-deliberate.json index 2d27ac73..ff834d96 100644 --- a/examples/flows/meta-deliberate.json +++ b/examples/flows/meta-deliberate.json @@ -1,7 +1,7 @@ { "id": "meta-deliberate", - "name": "meta-deliberate", - "description": "Meta-intelligence: a planner absorbs the question first (what is really asked, required steps, known pitfalls, a correctness checklist), an executor answers following the plan, and a verifier checks the answer against the plan's own checklist and repairs failures. Plan-and-Solve pattern; comparable 1:1 against an isolated LLM call.", + "name": "Plan, answer, verify", + "description": "Plans the question (steps, pitfalls, checklist), answers following the plan, then checks the answer against the checklist and fixes it.", "interfaces": [ "abstractcode.agent.v1", "abstractmeta.intelligence.v1" diff --git a/examples/flows/meta-perspectives.json b/examples/flows/meta-perspectives.json index 19efea5a..1d6a4394 100644 --- a/examples/flows/meta-perspectives.json +++ b/examples/flows/meta-perspectives.json @@ -1,7 +1,7 @@ { "id": "meta-perspectives", - "name": "meta-perspectives", - "description": "Meta-intelligence: a decomposer picks the 3 most revealing angles for THIS question (disciplines, stances, timescales, stakeholders...), each angle answers independently, then an integrator weaves them into one answer that names the tensions between angles instead of papering over them. Comparable 1:1 against an isolated LLM call.", + "name": "Three perspectives", + "description": "Picks three angles on the question, answers from each separately, then combines them into one answer that names where they disagree.", "interfaces": [ "abstractcode.agent.v1", "abstractmeta.intelligence.v1" diff --git a/examples/flows/meta-reflect.json b/examples/flows/meta-reflect.json index e4d5b837..81aa9c8d 100644 --- a/examples/flows/meta-reflect.json +++ b/examples/flows/meta-reflect.json @@ -1,7 +1,7 @@ { "id": "meta-reflect", - "name": "meta-reflect", - "description": "Meta-intelligence: draft an answer, then an introspection pass interrogates the reasoning itself (assumptions, weakest step, missed cases, overconfidence), then a revision folds the introspection into the final answer. Reflexion/Self-Refine pattern; comparable 1:1 against an isolated LLM call.", + "name": "Reflect and revise", + "description": "Drafts an answer, questions its own reasoning (assumptions, weak steps, missed cases), then writes a revised final answer.", "interfaces": [ "abstractcode.agent.v1", "abstractmeta.intelligence.v1" diff --git a/examples/flows/structured-extract.json b/examples/flows/structured-extract.json index 92eda844..a907ef68 100644 --- a/examples/flows/structured-extract.json +++ b/examples/flows/structured-extract.json @@ -1,7 +1,7 @@ { "id": "structured-extract", - "name": "structured-extract", - "description": "Reusable extraction primitive: an LLM extracts JSON from source text against a caller-supplied JSON Schema; a deterministic validator checks required keys/types and feeds specific errors back as a correction reprompt until the output is valid or the attempt budget is spent.", + "name": "Structured extraction", + "description": "Extracts JSON matching a given schema from text, checks required fields and types, and retries with the errors until it is valid.", "interfaces": [ "abstractextract.structured.v1" ], diff --git a/llms-full.txt b/llms-full.txt index 1b1b19ee..eb92daf5 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -17,9 +17,9 @@ npx @abstractframework/flow --gateway-url http://127.0.0.1:8080 ``` For a local checkout, clone [AbstractUIC](https://github.com/lpalbou/AbstractUIC) -next to this repository (`../abstractuic`): the editor builds the shared UI -packages (`@abstractframework/ui-kit` and the monitor widgets) from that sibling -checkout. +next to this repository (`../abstractuic`): the editor builds the monitor +widgets from that sibling checkout. `@abstractframework/ui-kit` installs from +npm with `npm install`. ```bash git clone https://github.com/lpalbou/AbstractUIC.git ../abstractuic @@ -228,7 +228,7 @@ The canvas reflects that split: Start here: - [Getting started](getting-started.md): start a Gateway, run the editor, sign in, and author a first workflow. -- [Web editor](web-editor.md): the editor's features: sign-in, About, the responsive layout on phones and tablets, discovery, Resources, flow interfaces, automation defaults and trigger-source discovery, hidden connections, the authoring assistant, media and file nodes. +- [Web editor](web-editor.md): the editor's features: sign-in, About, the responsive layout on phones and tablets, switches, discovery, Resources, flow interfaces, automation defaults and trigger-source discovery, hidden connections, the authoring assistant, media and file nodes. - [Troubleshooting](troubleshooting.md): symptoms, causes, and fixes for sign-in, proxy, editor, and local development problems. - [FAQ](faq.md): short answers about what Flow owns and what it delegates. @@ -308,7 +308,7 @@ for compatible models only; Flow itself does not impose that default. ## Local Development -A local checkout needs Node.js 20+ and a sibling [AbstractUIC](https://github.com/lpalbou/AbstractUIC) checkout: the editor builds the shared UI packages (`@abstractframework/ui-kit` and the monitor widgets) from `../abstractuic`. +A local checkout needs Node.js 20+ and a sibling [AbstractUIC](https://github.com/lpalbou/AbstractUIC) checkout: the editor builds the monitor widgets from `../abstractuic`. `@abstractframework/ui-kit` installs from npm with `npm install`. ```bash git clone https://github.com/lpalbou/AbstractUIC.git abstractuic @@ -411,24 +411,49 @@ the window width: touch screens. The minimap starts collapsed on phones and short screens; use the toggle in the bottom-right corner to show it. The view fits the flow again when you rotate the device. -- **Flow library and run window on phones.** The library shows the flow list - above the preview (side by side in landscape). The run window shows the - execution steps above the details. Both scroll inside the sheet. +- **Flow library and run window on phones and tablets.** Below 1024 px the + library shows the flow list above the preview (side by side in phone + landscape), and the run window shows the execution steps above the step + details. Each sheet scrolls as one page; the lists do not scroll inside + their own box. Sections are flat, with a thin line between items. The + list's header (**Flows**, **Execution**) hides or shows the list with a + tap, a click, Enter or Space; lists start open and the editor remembers + your choice in this browser. On phones the run window uses the width of + the screen with a 12 px margin, and label and value share a line when they + fit. +- **Side panel tabs on phones.** The Assistant, Properties and Functions tabs + sit in a row at the top of the drawer, so the open panel uses the full + width. - **On-screen keyboard.** The assistant composer and the sheets' action rows, such as the library's Load button, stay above the keyboard. In phone landscape a 200 px keyboard leaves room for the composer; a taller keyboard leaves less space than the composer needs. - **Touch targets and text.** Toolbar buttons, palette nodes, library rows, - selects and dialog buttons are at least 44 px on touch screens. Inputs use - 16 px text so iOS does not zoom in when you focus them, and the properties - panel's help text is 14 px. + selects and dialog buttons are at least 44 px on touch screens. Text on + touch screens uses a larger scale: body text 14 to 15 px, secondary text + 13 px. Inputs use 16 px text so iOS does not zoom in when you focus them. + Your font-size setting multiplies every size. - **Notifications** appear at the top of the screen on phones, clear of the sheets' action rows, and at the bottom right elsewhere. What stays desktop-sized: node cards and the controls inside them (pin inputs, pin selects, "Edit Code") keep their compact design and scale with -the canvas zoom, so zoom in to work on them on a small screen. The desktop -panels keep their dense 11-13 px text on a mouse-driven screen. +the canvas zoom, so zoom in to work on them on a small screen. On a desktop +with a mouse, the panels keep their dense layout with text at 12 px or more +(secondary text 13 px). + +## Switches + +On/off settings are switches labelled by what they control; a switch is +highlighted with a check mark when on and plain when off. The properties +panel has **Structured output** (Agent), **Recurrent** (On Schedule) and +**Free text answers** (Ask User). The dialogs have **Reload gateway bundles** +(Publish), **New folder per run** (the run window's workspace folder), +**Durable** (sending an event to a waiting run), and **Lock after load** and +**Cached and non-resident models** (Resources). When a switch cannot change, +it says why next to it: **New folder per run** is fixed while the gateway +manages the workspace or a run is in progress. Pause and Resume on a running +run are buttons, because they act on the run once. ## Provider And Model Discovery @@ -459,9 +484,9 @@ three tabs: length (✓ marks a calibrated value). Locked models carry a lock marker; Lock/Unlock and a per-model context Estimate appear when Gateway advertises those endpoints. Unloading a locked model asks for an explicit Force Unload - confirmation. A "Show cached/non-resident" toggle reveals cached and - configuration-only rows, and the load bar can lock a model as part of - loading it. + confirmation. The **Cached and non-resident models** switch reveals cached + and configuration-only rows, and the load bar's **Lock after load** switch + locks a model as part of loading it. - **Memory** shows host RAM, device, and per-GPU meters, process RSS, and degraded-state reasons from Gateway host state, refreshed every 5 seconds while the tab is open. @@ -4621,6 +4646,21 @@ Bundle versions are immutable by sha, so publishing a changed graph means bumping the version in the generator and updating the packaging pin in `abstractgateway/pyproject.toml`. +The name and one-line description each entrypoint shows on the Gateway's +Workflows page come from one table, `scripts/workflow_labels.py`. The +generators read it, and `scripts/relabel_shipped_bundles.py` keeps the shipped +bundles in line without repacking their graphs: + +```bash +python3 scripts/relabel_shipped_bundles.py check --bundles-dir ../abstractgateway/flows/bundles +python3 scripts/relabel_shipped_bundles.py apply --bundles-dir ../abstractgateway/flows/bundles +``` + +`check` fails when a generator, an example JSON or the latest shipped bundle +of a family carries other text, or when a shipped bundle has an entrypoint the +table does not name. `apply` rewrites only the labels of the latest version of +each family; older versions are never touched. + None of these shipped flows cuts what a model reads (ADR-0026): no message or character caps on prompts, tool output or documents, and no default output-token caps. The only bounds left are labeled display previews (for example, a @@ -5237,12 +5277,27 @@ it through the interface receives missing or mistyped values. editor at build time. - **Fix:** run `npm run build` again and reload the editor tab. +### Voice is unavailable, or Copy says "Copy failed — select and copy" + +- **Cause:** the editor is open over plain `http://` from another machine (a + LAN or Tailscale address such as `http://100.x.y.z:8080/apps/flow/`). + Browsers offer the microphone, the camera and the clipboard only on https + pages or on `localhost`. Over plain http, a voice wait in the run window + disables **Record** and says why, and **Copy** falls back to a + text-selection copy that the browser may refuse. +- **Fix:** open the editor over https, or on the gateway's own computer. With + Tailscale, run `tailscale serve --bg http://127.0.0.1:` on the gateway + machine and open `https://..ts.net/apps/flow/`; the gateway + console's Network page explains the steps. Everything else in the editor + works over plain http. + ## Local Development ### The build cannot resolve `@abstractframework/ui-kit` or a monitor package -- **Cause:** a local checkout builds the shared UI packages from a sibling - AbstractUIC checkout at `../abstractuic`. +- **Cause:** `@abstractframework/ui-kit` installs from npm, so `npm install` + has not run yet; or the monitor widgets, which a local checkout builds from + a sibling AbstractUIC checkout at `../abstractuic`, are missing. - **Fix:** ```bash @@ -5270,6 +5325,82 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.0] - 2026-10-01 + +Phones and tablets get more of the screen for reading, and on/off options in +the editor are switches labelled by what they control. Desktop windows 1024 px +or wider with a mouse keep the 0.5.0 layout, with text at 12 px or more. Anyone editing node properties, +publishing, starting a run or using the Resources panel sees the switches. +Nothing changes in VisualFlow documents, the server, or the Gateway contract. + +### Added +- **Collapsible lists.** In the flow library and the run window, the list + above the detail has a header you tap to hide or show it (Flows, + Execution), with a mouse, a tap, or Enter and Space. Lists start open. The + editor remembers your choice in this browser. Hiding the list gives the + preview or the step details the whole sheet. + +### Changed +- **Plain names and descriptions for the shipped workflows.** Every shipped + entrypoint has a name a person would use ("Basic agent", "Coding agent + (chat)", "Deep research") and one sentence of at most 140 characters that + says what it takes and what it returns, instead of its id and a developer + note. The text lives in one table, `scripts/workflow_labels.py`; the + generators take it from there, and `scripts/relabel_shipped_bundles.py` + writes it into the latest shipped bundle of each family and the example + JSONs (`apply`) and checks that generators, examples and bundles agree + (`check`). Only the labels change: graphs, versions and older versions stay + as they were, except docs-qa, which the gateway keeps in its catalog and + therefore ships the new text as 0.1.2. +- **One scroll per sheet.** On phones and tablets, the flow library, the run + window and the run history scroll as one page. Lists no longer scroll inside + their own box, and sections are flat, with a thin line between items. +- **Full width on phones.** The run window's text uses the width of the + screen: a 12 px margin, label and value on one line when they fit, JSON and + paths without a box. The assistant's replies are plain text across the + drawer. The Assistant, Properties and Functions tabs sit in a row at the + top, so the canvas and the open panel lose no side column. In the flow + library, a flow's name starts at the margin and the expand arrow sits at + the end of its row. +- **Larger text on touch screens.** Phones and tablets use a larger type + scale: body text 14 to 15 px, secondary text 13 px. Your font-size setting + still applies. Text inside canvas nodes keeps its size and grows when you + zoom. +- **No text under 12 px on desktops.** Dense text (badges, keys, list + details) is at least 12 px and secondary text 13 px, so the palette, the + properties panel and the run history read at 12 to 13 px instead of 11 px. + Some palette names are shortened one character sooner. +- Dialogs opened from the toolbar are no longer inside the page header, so + screen readers no longer read them as part of the header. + +### Fixed +- On phones, a notice raised while a dialog is open appears below the + dialog's title bar. It used to cover the run window's close button, and a + tap copied the error instead of closing the window. +- **Switches show their state.** A switch is highlighted with a check mark + when on, plain when off. +- **Node settings.** **Structured output** (Agent), **Recurrent** (On + Schedule) and **Free text answers** (Ask User) are switches in the + properties panel. Recurrent no longer shows an "Enabled" / "Disabled" label. +- **Dialog options.** **Reload gateway bundles** (Publish), **New folder per + run** (the run window's workspace folder), **Durable** (sending an event to + a waiting run), **Lock after load** and **Cached and non-resident models** + (Resources) are switches. In the run window, New folder per run shows why it + cannot change while the gateway manages the workspace or a run is in + progress. +- Pause and Resume on a running run stay buttons: they act on the run once, + they are not a setting. +- **Plain http from another machine** (LAN, Tailscale). Browsers withhold + `crypto.randomUUID`, the clipboard API and the microphone outside https and + localhost. Request, session and switch-case ids now come from the kit's + `randomId()` (v4 UUIDs everywhere), every **Copy** falls back to a + text-selection copy and says "Copied" or "Copy failed — select and copy", + and a voice wait over plain http disables **Record** and says why: "This + page is loaded over http, so voice and camera is unavailable — open it over + https (for example through tailscale serve; the gateway console's Network + page explains how) or on the gateway's own computer." Needs + `@abstractframework/ui-kit` 0.4.0. + ## [0.5.0] - 2026-09-30 The editor works on phones, tablets, and browser windows of any size. On a @@ -6114,7 +6245,7 @@ Requirements: - Node.js 20+ (the CI uses Node.js 24) - npm -- a sibling [AbstractUIC](https://github.com/lpalbou/AbstractUIC) checkout at `../abstractuic`: the editor builds the shared UI packages (`@abstractframework/ui-kit` and the monitor widgets) from it +- a sibling [AbstractUIC](https://github.com/lpalbou/AbstractUIC) checkout at `../abstractuic`: the editor builds the monitor widgets from it (`@abstractframework/ui-kit` installs from npm) - a reachable AbstractGateway for integration testing ```bash diff --git a/llms.txt b/llms.txt index 7da86f7f..008c51db 100644 --- a/llms.txt +++ b/llms.txt @@ -23,13 +23,13 @@ Key behavior: - [README.md](README.md): project overview, install, and feature summary - [docs/README.md](docs/README.md): documentation index - [docs/getting-started.md](docs/getting-started.md): start a Gateway, run the editor, sign in, local development -- [docs/web-editor.md](docs/web-editor.md): editor features, sign-in, About, responsive layout (phones, tablets, tap-to-add), interfaces, automation defaults and trigger-source discovery, hidden connections, authoring assistant, media and file nodes +- [docs/web-editor.md](docs/web-editor.md): editor features, sign-in, About, responsive layout (phones, tablets, tap-to-add, collapsible lists), switches, interfaces, automation defaults and trigger-source discovery, hidden connections, authoring assistant, media and file nodes - [docs/architecture.md](docs/architecture.md): components, boundaries, and diagrams - [docs/api.md](docs/api.md): CLI options, environment variables, proxy contract, frontend modules - [docs/visualflow.md](docs/visualflow.md): VisualFlow JSON format, interfaces and their boundary pins, `automation_defaults`, pin expressions - [docs/cli.md](docs/cli.md): the `abstractflow-editor` command - [docs/faq.md](docs/faq.md): short answers about scope and behavior -- [docs/troubleshooting.md](docs/troubleshooting.md): sign-in, proxy, editor, and build symptoms with fixes +- [docs/troubleshooting.md](docs/troubleshooting.md): sign-in, proxy, editor, plain-http (voice, copy), and build symptoms with fixes ## Workflow Authoring diff --git a/package-lock.json b/package-lock.json index 4747d43d..58dd0511 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@abstractframework/flow", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@abstractframework/flow", - "version": "0.5.0", + "version": "0.6.0", "license": "MIT", "dependencies": { "@abstractframework/app-server": "^0.1.11", - "@abstractframework/ui-kit": "^0.3.2", + "@abstractframework/ui-kit": "^0.4.0", "@monaco-editor/react": "^4.6.0", "@tanstack/react-query": "^5.17.0", "clsx": "^2.1.0", @@ -50,9 +50,9 @@ } }, "node_modules/@abstractframework/ui-kit": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@abstractframework/ui-kit/-/ui-kit-0.3.2.tgz", - "integrity": "sha512-vzsQSHxoVkPYfcwERlxr8iiykYGbQheGHEsRmUJnSFq/Ijb5jbWJ0aDmjHsQdzs/BDMdU5v67eAcyfxDo3SR4Q==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@abstractframework/ui-kit/-/ui-kit-0.4.0.tgz", + "integrity": "sha512-C18n7TJfC5PiK24vfe4KB8VUioAvlQXvXDbRzHAH4hdNACLVNVk+51WDNy/FW1mSfxz11sQ1SoDuKqge3nsPZQ==", "license": "MIT", "engines": { "node": ">=18.0.0" diff --git a/package.json b/package.json index 0eebdf61..46da569d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@abstractframework/flow", - "version": "0.5.0", + "version": "0.6.0", "description": "AbstractFlow visual workflow editor for AbstractFramework", "type": "module", "author": "Laurent-Philippe Albou", @@ -43,7 +43,7 @@ }, "dependencies": { "@abstractframework/app-server": "^0.1.11", - "@abstractframework/ui-kit": "^0.3.2", + "@abstractframework/ui-kit": "^0.4.0", "@monaco-editor/react": "^4.6.0", "@tanstack/react-query": "^5.17.0", "clsx": "^2.1.0", diff --git a/scripts/build_adversarial_review_workflow.py b/scripts/build_adversarial_review_workflow.py index 8454a34a..de7b8f0f 100644 --- a/scripts/build_adversarial_review_workflow.py +++ b/scripts/build_adversarial_review_workflow.py @@ -12,6 +12,7 @@ from __future__ import annotations import wf_common as W +from workflow_labels import label CRITIC_SCHEMA = { "type": "object", "additionalProperties": False, @@ -117,8 +118,7 @@ def _critic(node_id, label, x, y): def build_flow(): flow = W.base_flow( - "adversarial-review", "adversarial-review", - "Reusable review primitive: three independent critics review an artifact through correctness / design-robustness / requirements-fit lenses, then a merge step consolidates and severity-ranks the findings into one pass/revise/block verdict. Composable as a subflow.", + "adversarial-review", *label("adversarial-review", "adversarial-review"), ["abstractreview.adversarial.v1"], ) fields = [ diff --git a/scripts/build_co_scientist_workflow.py b/scripts/build_co_scientist_workflow.py index 02a45423..fc1da227 100644 --- a/scripts/build_co_scientist_workflow.py +++ b/scripts/build_co_scientist_workflow.py @@ -89,6 +89,7 @@ from __future__ import annotations import wf_common as W +from workflow_labels import label from wf_common import EXEC_IN, EXEC_OUT, code_node, edge, pin # =========================================================================== @@ -2106,8 +2107,7 @@ def reads(self, node_id, specs): def build_flow(): flow = W.base_flow( - "co-scientist", "co-scientist", - "Deep multi-agent hypothesis engine (Nature 'AI co-scientist' replica). Starts FROM the literature by delegating grounding to the deep-research investigation engine (deep-plan + deep-investigate: real web search + a verified source ledger), then runs a supervisor loop of Generation -> Reflection -> Elo-ranking (prioritized pairwise scientific debate) -> Evolution -> research-expansion, threading meta-feedback forward each cycle with near-duplicate pruning, then a final search-grounded full review of the finalists and a Meta-review research overview. Deliberately deeper than a single-pass report - it scales test-time compute via the cycle budget.", + "co-scientist", *label("co-scientist", "co-scientist"), ["abstractresearch.coscientist.v1"], ) G = Graph() diff --git a/scripts/build_coding_agent_workflow.py b/scripts/build_coding_agent_workflow.py index d20fe4c9..534c90b5 100644 --- a/scripts/build_coding_agent_workflow.py +++ b/scripts/build_coding_agent_workflow.py @@ -28,6 +28,11 @@ from pathlib import Path from typing import Any +# Entrypoint display names/descriptions: one table (workflow_labels.py), +# importable whether this file runs as a script or is loaded by path. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from workflow_labels import label # noqa: E402 + ROOT = Path(__file__).resolve().parents[2] FLOWS_DIR = ROOT / "abstractflow" / "examples" / "flows" BUNDLES_DIR = ROOT / "abstractgateway" / "flows" / "bundles" @@ -2591,8 +2596,7 @@ def build_verifier_subflow() -> dict[str, Any]: def build_root_flow() -> dict[str, Any]: flow = _base_flow( - "coding-agent", "coding-agent", - "Advanced coding agent with recursive verification: a builder agent writes code, an independent verifier runs build/execute/match gates each round, and failures are fed back as specific reprompts until all gates pass or the round budget is spent.", + "coding-agent", *label("coding-agent", "coding-agent"), # Own coding interface only — the boundary is request -> report/passed, # not the chat-agent prompt/response contract of abstractcode.agent.v1. ["abstractcode.coding.v1"], @@ -2891,8 +2895,7 @@ def build_chat_entrypoint_flow() -> dict[str, Any]: enforcement regardless of this flow's wiring. """ flow = _base_flow( - "coder", "coder", - "Chat-agent entrypoint for the coding-agent pipeline: takes a plain prompt (abstractcode.agent.v1), runs the verify-gated build loop in the session workspace with inferred gates (no explicit build/run commands — the verifier executes what it can and reports honestly), and returns the build report as the response.", + "coder", *label("coding-agent", "coder"), ["abstractcode.agent.v1"], ) # Layout: three-node exec spine at y=0 (start -> build -> end); the input diff --git a/scripts/build_deep_research_workflows.py b/scripts/build_deep_research_workflows.py index c5390699..0f55b8cc 100644 --- a/scripts/build_deep_research_workflows.py +++ b/scripts/build_deep_research_workflows.py @@ -13,6 +13,11 @@ from pathlib import Path from typing import Any +# Entrypoint display names/descriptions: one table (workflow_labels.py), +# importable whether this file runs as a script or is loaded by path. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from workflow_labels import label # noqa: E402 + ROOT = Path(__file__).resolve().parents[2] FLOWS_DIR = ROOT / "abstractflow" / "examples" / "flows" BUNDLES_DIR = ROOT / "abstractgateway" / "flows" / "bundles" @@ -1457,11 +1462,7 @@ def _write_node(node_id: str, node_type: str, label: str) -> dict[str, Any]: def build_root_flow() -> dict[str, Any]: flow = _base_flow( "deep-research", - "deep-research", - ( - "Production research workflow with adversarial review, provenance, and " - "Markdown/PDF/DOCX export." - ), + *label("deep-research", "deep-research"), ["abstractcode.agent.v1", "abstractresearch.deep.v1"], ) root_fields = _input_fields() diff --git a/scripts/build_map_reduce_workflow.py b/scripts/build_map_reduce_workflow.py index 950411c2..c0027f9f 100644 --- a/scripts/build_map_reduce_workflow.py +++ b/scripts/build_map_reduce_workflow.py @@ -12,6 +12,7 @@ from __future__ import annotations import wf_common as W +from workflow_labels import label RESET_CODE = "return []".strip() @@ -49,8 +50,7 @@ def build_flow(): flow = W.base_flow( - "map-reduce", "map-reduce", - "Reusable batch primitive: map a per-item LLM instruction over an input array (ForEach), accumulate the per-item results, then reduce them with a synthesis LLM instruction. Returns the per-item results and the synthesized output.", + "map-reduce", *label("map-reduce", "map-reduce"), ["abstractbatch.mapreduce.v1"], ) fields = [ diff --git a/scripts/build_meta_intelligence_workflows.py b/scripts/build_meta_intelligence_workflows.py index 9b14dd7b..62f80dec 100644 --- a/scripts/build_meta_intelligence_workflows.py +++ b/scripts/build_meta_intelligence_workflows.py @@ -60,6 +60,7 @@ from __future__ import annotations import wf_common as W +from workflow_labels import label BUNDLE_VERSION = "0.1.1" @@ -127,8 +128,7 @@ def build_consensus() -> dict: flow = W.base_flow( - "meta-consensus", "meta-consensus", - "Meta-intelligence: two independent LLM answers reconciled into one consensus answer — agreement is kept as reliable, divergence is resolved by reasoning, not averaged. Diversity comes from temperature (0.3 vs 0.9) by default, or from a genuinely different second model via the optional provider_b/model_b inputs. Self-consistency pattern; comparable 1:1 against an isolated LLM call on the same prompt. Consensus across N>2 answerers needs a loop-based flow (future scope).", + "meta-consensus", *label("meta-consensus", "meta-consensus"), [W.AGENT_INTERFACE, "abstractmeta.intelligence.v1"], ) flow["nodes"] = [ @@ -263,8 +263,7 @@ def build_consensus() -> dict: def build_debate() -> dict: flow = W.base_flow( - "meta-debate", "meta-debate", - "Meta-intelligence: proposer answers, an adversarial challenger attacks the answer (flaws, counterexamples, missing cases), then the proposer concedes-or-rebuts point by point and writes the defended final answer. Multi-agent debate pattern; comparable 1:1 against an isolated LLM call.", + "meta-debate", *label("meta-debate", "meta-debate"), [W.AGENT_INTERFACE, "abstractmeta.intelligence.v1"], ) flow["nodes"] = [ @@ -392,8 +391,7 @@ def build_debate() -> dict: def build_reflect() -> dict: flow = W.base_flow( - "meta-reflect", "meta-reflect", - "Meta-intelligence: draft an answer, then an introspection pass interrogates the reasoning itself (assumptions, weakest step, missed cases, overconfidence), then a revision folds the introspection into the final answer. Reflexion/Self-Refine pattern; comparable 1:1 against an isolated LLM call.", + "meta-reflect", *label("meta-reflect", "meta-reflect"), [W.AGENT_INTERFACE, "abstractmeta.intelligence.v1"], ) flow["nodes"] = [ @@ -562,8 +560,7 @@ def build_reflect() -> dict: def build_perspectives() -> dict: flow = W.base_flow( - "meta-perspectives", "meta-perspectives", - "Meta-intelligence: a decomposer picks the 3 most revealing angles for THIS question (disciplines, stances, timescales, stakeholders...), each angle answers independently, then an integrator weaves them into one answer that names the tensions between angles instead of papering over them. Comparable 1:1 against an isolated LLM call.", + "meta-perspectives", *label("meta-perspectives", "meta-perspectives"), [W.AGENT_INTERFACE, "abstractmeta.intelligence.v1"], ) angle_defaults = {"system": "You answer questions from one assigned angle only, sharply — balance is someone else's job.", "temperature": 0.6} @@ -744,8 +741,7 @@ def build_perspectives() -> dict: def build_deliberate() -> dict: flow = W.base_flow( - "meta-deliberate", "meta-deliberate", - "Meta-intelligence: a planner absorbs the question first (what is really asked, required steps, known pitfalls, a correctness checklist), an executor answers following the plan, and a verifier checks the answer against the plan's own checklist and repairs failures. Plan-and-Solve pattern; comparable 1:1 against an isolated LLM call.", + "meta-deliberate", *label("meta-deliberate", "meta-deliberate"), [W.AGENT_INTERFACE, "abstractmeta.intelligence.v1"], ) flow["nodes"] = [ @@ -830,8 +826,7 @@ def build_deliberate() -> dict: def build_baseline() -> dict: flow = W.base_flow( - "meta-baseline", "meta-baseline", - "Control arm for the meta-intelligence benchmark: ONE isolated LLM call answering directly (same system prompt, same machinery, no orchestration). Conforms to abstractcode.agent.v1.", + "meta-baseline", *label("meta-baseline", "meta-baseline"), [W.AGENT_INTERFACE, "abstractmeta.intelligence.v1"], ) flow["nodes"] = [ diff --git a/scripts/build_structured_extract_workflow.py b/scripts/build_structured_extract_workflow.py index ceaf80fa..fb7e916d 100644 --- a/scripts/build_structured_extract_workflow.py +++ b/scripts/build_structured_extract_workflow.py @@ -10,6 +10,7 @@ from __future__ import annotations import wf_common as W +from workflow_labels import label BUILD_PROMPT_CODE = """ src = str(source_text or "").strip() @@ -104,8 +105,7 @@ def _type_ok(val, t): def build_flow(): flow = W.base_flow( - "structured-extract", "structured-extract", - "Reusable extraction primitive: an LLM extracts JSON from source text against a caller-supplied JSON Schema; a deterministic validator checks required keys/types and feeds specific errors back as a correction reprompt until the output is valid or the attempt budget is spent.", + "structured-extract", *label("structured-extract", "structured-extract"), ["abstractextract.structured.v1"], ) fields = [ diff --git a/scripts/relabel_shipped_bundles.py b/scripts/relabel_shipped_bundles.py new file mode 100644 index 00000000..b5f794d9 --- /dev/null +++ b/scripts/relabel_shipped_bundles.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +"""Write and check the entrypoint names/descriptions of the shipped workflows. + +The gateway shows, per bundle, the default entrypoint's `name` and +`description` from the bundle manifest (and per version, every entrypoint's). +`workflow_labels.py` holds that text; this tool puts it where it is read: + + apply — for every bundle id in the table, the LATEST version in the bundles + dir (every file at that version: basic-agent ships as both + basic-agent.flow and basic-agent@0.0.5.flow) gets the table's + name/description in its manifest entrypoints and in each + entrypoint's VisualFlow JSON (`name`, `description`). Nothing else + changes: every other zip member keeps its bytes, the manifest keeps + every other field (created_at included), and a JSON member that + would not re-serialize byte-identically stops the run. Older + versions are never touched. Bundles listed in NEW_VERSIONS are + written as a NEW file at that version instead (a bundle the gateway + publishes into its catalog is immutable by sha, so its text change + needs a version bump); the source version stays as it is. The + example VisualFlow JSONs this repository builds from + (examples/flows/.json) are relabelled the same way. + check — no writes. Fails (exit 1) when the table breaks its rules, when a + build script would produce other text, when an example JSON or a + latest shipped bundle carries other text, or when a latest shipped + bundle has an entrypoint the table does not name. + +Usage: + relabel_shipped_bundles.py apply --bundles-dir ../abstractgateway/flows/bundles + relabel_shipped_bundles.py check --bundles-dir ../abstractgateway/flows/bundles +""" + +from __future__ import annotations + +import argparse +import importlib.util +import io +import json +import sys +import zipfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable + +HERE = Path(__file__).resolve().parent +REPO_ROOT = HERE.parent +EXAMPLES_DIR = REPO_ROOT / "examples" / "flows" +sys.path.insert(0, str(HERE)) + +from workflow_labels import ENTRYPOINT_LABELS, table_problems # noqa: E402 + +# bundle id -> (version to write, version to copy from). docs-qa is published +# into the gateway's tenant catalog at boot, where a version is immutable by +# sha (abstractgateway shipped_catalog.py), so its new text ships as 0.1.2. +NEW_VERSIONS: dict[str, tuple[str, str]] = {"docs-qa": ("0.1.2", "0.1.1")} + +# The build functions that produce each entrypoint flow built in this +# repository: (script file, function name, flow id). `check` calls them and +# compares the `name`/`description` they emit with the table. +BUILDERS: tuple[tuple[str, str, str], ...] = ( + ("build_map_reduce_workflow.py", "build_flow", "map-reduce"), + ("build_structured_extract_workflow.py", "build_flow", "structured-extract"), + ("build_adversarial_review_workflow.py", "build_flow", "adversarial-review"), + ("build_co_scientist_workflow.py", "build_flow", "co-scientist"), + ("build_coding_agent_workflow.py", "build_root_flow", "coding-agent"), + ("build_coding_agent_workflow.py", "build_chat_entrypoint_flow", "coder"), + ("build_deep_research_workflows.py", "build_root_flow", "deep-research"), + ("build_meta_intelligence_workflows.py", "build_consensus", "meta-consensus"), + ("build_meta_intelligence_workflows.py", "build_debate", "meta-debate"), + ("build_meta_intelligence_workflows.py", "build_reflect", "meta-reflect"), + ("build_meta_intelligence_workflows.py", "build_perspectives", "meta-perspectives"), + ("build_meta_intelligence_workflows.py", "build_deliberate", "meta-deliberate"), + ("build_meta_intelligence_workflows.py", "build_baseline", "meta-baseline"), +) + +# Entrypoint flows whose source is an example JSON edited in AbstractFlow (no +# generator): basic-agent's root. build_basic_agent_bundle.py packs it. +HAND_EDITED_SOURCES: tuple[str, ...] = ("81795ea9",) + + +def fail(msg: str) -> None: + print(f"RELABEL FAIL: {msg}", file=sys.stderr) + + +def _labels_by_flow() -> dict[str, tuple[str, str]]: + out: dict[str, tuple[str, str]] = {} + for entries in ENTRYPOINT_LABELS.values(): + out.update(entries) + return out + + +def _json_dump_like(original: bytes, data: Any) -> bytes: + """Serialize `data` the way `original` was serialized, or raise. + + Two layouts exist in the shipped artifacts: indent=2 with and without a + trailing newline, with or without ASCII escaping. The layout is detected + by re-serializing the ORIGINAL; if none reproduces it byte for byte, the + member is not rewritten (a rewrite would change more than the labels).""" + parsed = json.loads(original.decode("utf-8")) + for ensure_ascii in (False, True): + for suffix in ("\n", ""): + if (json.dumps(parsed, indent=2, ensure_ascii=ensure_ascii) + suffix).encode("utf-8") == original: + return (json.dumps(data, indent=2, ensure_ascii=ensure_ascii) + suffix).encode("utf-8") + raise ValueError("does not re-serialize byte-identically; refusing to rewrite it") + + +def _version_key(v: str) -> tuple: + parts = [] + for p in v.split("."): + parts.append((0, int(p), "") if p.isdigit() else (1, 0, p)) + return tuple(parts) + + +def _scan(bundles_dir: Path) -> dict[str, dict[str, list[Path]]]: + """bundle_id -> version -> [files], from each file's manifest.""" + out: dict[str, dict[str, list[Path]]] = {} + for path in sorted(bundles_dir.glob("*.flow")): + with zipfile.ZipFile(path) as zf: + man = json.loads(zf.read("manifest.json").decode("utf-8")) + out.setdefault(str(man["bundle_id"]), {}).setdefault(str(man["bundle_version"]), []).append(path) + return out + + +def _latest_files(scan: dict[str, dict[str, list[Path]]], bundle_id: str) -> tuple[str, list[Path]]: + versions = scan.get(bundle_id) or {} + if not versions: + return "", [] + latest = max(versions, key=_version_key) + return latest, versions[latest] + + +def _relabel_zip(src: Path, dest: Path, bundle_id: str, *, new_version: str | None = None) -> list[str]: + """Rewrite `src` into `dest` with the table's labels. Returns what changed.""" + labels = ENTRYPOINT_LABELS[bundle_id] + changed: list[str] = [] + with zipfile.ZipFile(src) as zf: + infos = zf.infolist() + members = {i.filename: zf.read(i.filename) for i in infos} + man_raw = members["manifest.json"] + man = json.loads(man_raw.decode("utf-8")) + for ep in man.get("entrypoints") or []: + fid = str(ep.get("flow_id") or "") + if fid not in labels: + raise KeyError(f"{src.name}: entrypoint {fid!r} has no entry in workflow_labels.py") + name, desc = labels[fid] + if ep.get("name") != name or ep.get("description") != desc: + changed.append(f"manifest {fid}: {ep.get('name')!r} -> {name!r}") + ep["name"], ep["description"] = name, desc + rel = (man.get("flows") or {}).get(fid) + if rel: + flow_raw = members[rel] + flow = json.loads(flow_raw.decode("utf-8")) + if flow.get("name") != name or flow.get("description") != desc: + changed.append(f"{rel}: name/description") + flow["name"], flow["description"] = name, desc + try: + members[rel] = _json_dump_like(flow_raw, flow) + except ValueError as exc: + raise ValueError(f"{src.name}:{rel} {exc}") from exc + if new_version is not None: + # A new version is a new artifact: its own version and creation time. + man["bundle_version"] = new_version + man["created_at"] = datetime.now(timezone.utc).isoformat() + changed.append(f"bundle_version -> {new_version}") + try: + members["manifest.json"] = _json_dump_like(man_raw, man) + except ValueError as exc: + raise ValueError(f"{src.name}:manifest.json {exc}") from exc + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as out: + for info in infos: + out.writestr(info, members[info.filename], compress_type=info.compress_type) + tmp = dest.with_name(dest.name + ".tmp") + tmp.write_bytes(buf.getvalue()) + tmp.replace(dest) + return changed + + +def _relabel_example(flow_id: str) -> bool: + path = EXAMPLES_DIR / f"{flow_id}.json" + raw = path.read_bytes() + flow = json.loads(raw.decode("utf-8")) + name, desc = _labels_by_flow()[flow_id] + if flow.get("name") == name and flow.get("description") == desc: + return False + flow["name"], flow["description"] = name, desc + path.write_bytes(_json_dump_like(raw, flow)) + return True + + +def apply(bundles_dir: Path) -> int: + problems = table_problems() + if problems: + for p in problems: + fail(p) + return 1 + for flow_id in [b[2] for b in BUILDERS] + list(HAND_EDITED_SOURCES): + if _relabel_example(flow_id): + print(f"relabelled examples/flows/{flow_id}.json") + scan = _scan(bundles_dir) + for bundle_id in ENTRYPOINT_LABELS: + if bundle_id in NEW_VERSIONS: + new_v, from_v = NEW_VERSIONS[bundle_id] + if new_v not in (scan.get(bundle_id) or {}): + sources = (scan.get(bundle_id) or {}).get(from_v) or [] + if len(sources) != 1: + fail(f"{bundle_id}@{from_v}: expected exactly one source file, found {len(sources)}") + return 1 + dest = bundles_dir / f"{bundle_id}@{new_v}.flow" + changes = _relabel_zip(sources[0], dest, bundle_id, new_version=new_v) + print(f"wrote {dest.name} from {sources[0].name}: {'; '.join(changes)}") + scan = _scan(bundles_dir) + version, files = _latest_files(scan, bundle_id) + if not files: + fail(f"{bundle_id}: no bundle in {bundles_dir}") + return 1 + for path in files: + changes = _relabel_zip(path, path, bundle_id) + print(f"{path.name} ({bundle_id}@{version}): {'; '.join(changes) if changes else 'already labelled'}") + return 0 + + +def _load_module(filename: str): + spec = importlib.util.spec_from_file_location(f"_labels_check_{filename[:-3]}", HERE / filename) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod + + +def check(bundles_dir: Path) -> int: + errors: list[str] = list(table_problems()) + by_flow = _labels_by_flow() + + modules: dict[str, Any] = {} + for filename, func, flow_id in BUILDERS: + mod = modules.get(filename) or _load_module(filename) + modules[filename] = mod + builder: Callable[[], dict] = getattr(mod, func) + flow = builder() + want = by_flow[flow_id] + got = (flow.get("name"), flow.get("description")) + if flow.get("id") != flow_id: + errors.append(f"{filename}:{func} builds flow {flow.get('id')!r}, expected {flow_id!r}") + elif got != want: + errors.append(f"{filename}:{func} emits name/description {got!r}, table says {want!r}") + + for flow_id in [b[2] for b in BUILDERS] + list(HAND_EDITED_SOURCES): + path = EXAMPLES_DIR / f"{flow_id}.json" + flow = json.loads(path.read_text(encoding="utf-8")) + if (flow.get("name"), flow.get("description")) != by_flow[flow_id]: + errors.append(f"examples/flows/{flow_id}.json: name/description differ from the table") + + scan = _scan(bundles_dir) + for bundle_id in scan: + if bundle_id not in ENTRYPOINT_LABELS: + errors.append(f"{bundle_id}: shipped in {bundles_dir} but has no entry in workflow_labels.py") + for bundle_id, labels in ENTRYPOINT_LABELS.items(): + version, files = _latest_files(scan, bundle_id) + if not files: + errors.append(f"{bundle_id}: no bundle in {bundles_dir}") + continue + if bundle_id in NEW_VERSIONS and version != NEW_VERSIONS[bundle_id][0]: + errors.append(f"{bundle_id}: latest shipped is {version}, expected {NEW_VERSIONS[bundle_id][0]}") + for path in files: + with zipfile.ZipFile(path) as zf: + man = json.loads(zf.read("manifest.json").decode("utf-8")) + for ep in man.get("entrypoints") or []: + fid = str(ep.get("flow_id") or "") + want = labels.get(fid) + if want is None: + errors.append(f"{path.name}: entrypoint {fid!r} has no entry in workflow_labels.py") + continue + if (ep.get("name"), ep.get("description")) != want: + errors.append(f"{path.name}: manifest entrypoint {fid!r} carries {ep.get('name')!r} / {ep.get('description')!r}") + rel = (man.get("flows") or {}).get(fid) + if rel: + flow = json.loads(zf.read(rel).decode("utf-8")) + if (flow.get("name"), flow.get("description")) != want: + errors.append(f"{path.name}:{rel}: flow name/description differ from the table") + if errors: + for e in errors: + fail(e) + return 1 + count = sum(len(v) for v in ENTRYPOINT_LABELS.values()) + print(f"labels OK: {count} entrypoints in {len(ENTRYPOINT_LABELS)} bundles; build scripts, examples and latest shipped bundles agree") + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("mode", choices=("apply", "check")) + ap.add_argument("--bundles-dir", type=Path, required=True, help="the gateway's flows/bundles directory") + args = ap.parse_args() + bundles_dir = args.bundles_dir.expanduser().resolve() + if not bundles_dir.is_dir(): + fail(f"bundles dir does not exist: {bundles_dir}") + return 1 + if args.mode == "apply": + rc = apply(bundles_dir) + if rc: + return rc + return check(bundles_dir) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/workflow_labels.py b/scripts/workflow_labels.py new file mode 100644 index 00000000..aaa06a9b --- /dev/null +++ b/scripts/workflow_labels.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Display names and one-line descriptions of the shipped workflow entrypoints. + +One table, one copy. The gateway's Workflows page and TUI show, per bundle, the +default entrypoint's `name` and `description` from the bundle manifest, and the +packer copies both from the root VisualFlow JSON (`name`, `description`). So the +build scripts take them from here, and `relabel_shipped_bundles.py` writes them +into already-shipped bundles and checks that every source and bundle carries +them. + +Rules for an entry: the name is what a person calls the workflow; the +description is one plain sentence (at most 140 characters) that says what the +workflow takes and what it returns, derived from the flow definition, with no +marketing and no internal ticket or protocol references. + +Bundles built outside this repository are listed too (docs-qa and the native +agent loops are built by abstractgateway's scripts, the Assistant orchestrator +by abstractassistant): their shipped files are relabelled in place from this +table, and their own build scripts must carry the same text. +""" + +from __future__ import annotations + +MAX_DESCRIPTION_CHARS = 140 + +# bundle_id -> {entrypoint flow_id -> (name, description)} +ENTRYPOINT_LABELS: dict[str, dict[str, tuple[str, str]]] = { + "basic-agent": { + "81795ea9": ( + "Basic agent", + "Chat agent: answers a prompt in one agent loop that can use tools and memory, and returns the reply.", + ), + }, + "coding-agent": { + "coding-agent": ( + "Coding agent", + "Takes a build request and a workspace, writes the code, and runs build and run checks each round until they pass; returns a report.", + ), + "coder": ( + "Coding agent (chat)", + "Chat version of the coding agent: builds what the prompt asks in the session workspace, checks it each round, and replies with a report.", + ), + }, + "deep-research": { + "deep-research": ( + "Deep research", + "Takes a research question, searches the web, has three critics review the draft, and writes a cited report (Markdown, PDF, DOCX).", + ), + }, + "co-scientist": { + "co-scientist": ( + "Co-scientist", + "Takes a research goal, grounds it in web sources, then generates, debates and ranks hypotheses over cycles; returns a research overview.", + ), + }, + "map-reduce": { + "map-reduce": ( + "Map-reduce", + "Applies one LLM instruction to each item of a list, then combines the per-item results into one synthesis; returns both.", + ), + }, + "structured-extract": { + "structured-extract": ( + "Structured extraction", + "Extracts JSON matching a given schema from text, checks required fields and types, and retries with the errors until it is valid.", + ), + }, + "adversarial-review": { + "adversarial-review": ( + "Adversarial review", + "Three critics review text or code for correctness, design and fit to requirements; returns ranked findings and a pass/revise/block verdict.", + ), + }, + "meta-baseline": { + "meta-baseline": ( + "Single call (baseline)", + "Answers the prompt with one direct LLM call; the baseline the other deliberation workflows are compared against.", + ), + }, + "meta-consensus": { + "meta-consensus": ( + "Consensus", + "Gets two independent answers (two temperatures or two models) and merges them into one, keeping agreements and resolving differences.", + ), + }, + "meta-debate": { + "meta-debate": ( + "Debate", + "One model answers, a challenger attacks the answer, then the first model concedes or rebuts each point and writes the final answer.", + ), + }, + "meta-deliberate": { + "meta-deliberate": ( + "Plan, answer, verify", + "Plans the question (steps, pitfalls, checklist), answers following the plan, then checks the answer against the checklist and fixes it.", + ), + }, + "meta-perspectives": { + "meta-perspectives": ( + "Three perspectives", + "Picks three angles on the question, answers from each separately, then combines them into one answer that names where they disagree.", + ), + }, + "meta-reflect": { + "meta-reflect": ( + "Reflect and revise", + "Drafts an answer, questions its own reasoning (assumptions, weak steps, missed cases), then writes a revised final answer.", + ), + }, + # Built outside this repository (see the module docstring). + "docs-qa": { + "docsqa001": ( + "Docs Q&A", + "Answers a question about an app from that app's documentation (its llms.txt), citing it, and says so when the docs do not cover it.", + ), + }, + "abstractassistant-orchestrator": { + "d5d4e5a1": ( + "AbstractAssistant Orchestrator", + "The menu-bar Assistant's workflow: sends each request to a tools agent or to image, video or music generation, and returns the reply.", + ), + }, + "react-agent": { + "react": ( + "ReAct agent", + "Chat agent (ReAct): reasons step by step and calls tools until it can answer the prompt, then returns the reply.", + ), + }, + "codeact-agent": { + "codeact": ( + "CodeAct agent", + "Chat agent (CodeAct): works on the prompt mainly by writing and running Python code, then returns the reply.", + ), + }, + "memact-agent": { + "memact": ( + "MemAct agent", + "Chat agent (MemAct): keeps a long-term memory that it reads and updates on each turn, can call tools, and returns the reply.", + ), + }, +} + + +def label(bundle_id: str, flow_id: str) -> tuple[str, str]: + """(name, description) of one entrypoint. A missing entry is a KeyError: + a build must never fall back to a flow id as the display name.""" + return ENTRYPOINT_LABELS[bundle_id][flow_id] + + +def table_problems() -> list[str]: + """Rule violations in the table itself (empty = the table is valid).""" + problems: list[str] = [] + for bundle_id, entries in ENTRYPOINT_LABELS.items(): + for flow_id, (name, description) in entries.items(): + where = f"{bundle_id}:{flow_id}" + if not name.strip() or name.strip() in {flow_id, bundle_id}: + problems.append(f"{where}: the name must be a human name, not the id ({name!r})") + if not description.strip(): + problems.append(f"{where}: empty description") + if len(description) > MAX_DESCRIPTION_CHARS: + problems.append(f"{where}: description is {len(description)} characters (max {MAX_DESCRIPTION_CHARS})") + if "\n" in description: + problems.append(f"{where}: description spans several lines") + return problems diff --git a/src/components/AuthoringAssistantDrawer.tsx b/src/components/AuthoringAssistantDrawer.tsx index 9c787700..d9c1d2d9 100644 --- a/src/components/AuthoringAssistantDrawer.tsx +++ b/src/components/AuthoringAssistantDrawer.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction } from 'react'; import { useQuery } from '@tanstack/react-query'; import toast from 'react-hot-toast'; +import { clipboardWriteOrThrow } from '../lib/copy_feedback'; import authoringSkillText from '../../docs/workflow-authoring-skill.md?raw'; // Conceptual core docs the assistant uses to EXPLAIN AbstractFlow to users and // to author with real product grounding (operator ask 2026-07-15). Kept as raw @@ -3894,7 +3895,7 @@ export function AuthoringAssistantDrawer({ const copyActivity = async () => { try { - await navigator.clipboard.writeText( + await clipboardWriteOrThrow( activityClipboardText(workingStatus?.label || 'Authoring activity', activity, turnStartedAt) ); toast.success('Authoring activity copied'); @@ -3905,7 +3906,7 @@ export function AuthoringAssistantDrawer({ const copyConversation = async () => { try { - await navigator.clipboard.writeText( + await clipboardWriteOrThrow( assistantConversationClipboardText({ workflowKey: workflowStorageKey, flowId, @@ -4061,7 +4062,7 @@ export function AuthoringAssistantDrawer({ onAskReplyChange={setTestAskReply} onSendAskReply={() => void resolveTestWait({ response: testAskReply })} onOpenRun={(runId) => { - void navigator.clipboard.writeText(runId).then( + void clipboardWriteOrThrow(runId).then( () => toast.success('Run id copied — open it from Run History'), () => toast(`Run id: ${runId}`) ); diff --git a/src/components/FlowLibraryModal.tsx b/src/components/FlowLibraryModal.tsx index 6e809077..ea4dab57 100644 --- a/src/components/FlowLibraryModal.tsx +++ b/src/components/FlowLibraryModal.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { AfChip } from '@abstractframework/ui-kit'; +import { AfChip, useAfMedia } from '@abstractframework/ui-kit'; import type { AutomationDefaults, VisualFlow } from '../types/flow'; import type { GatewayContracts } from '../utils/gatewayClient'; import { describeAutomationDefaults } from '../utils/triggerBindings'; @@ -20,6 +20,8 @@ import { type LibraryViewMode, } from '../utils/flowLibraryRows'; import { FlowLibraryList } from './FlowLibraryList'; +import { ListDisclosure } from './ListDisclosure'; +import { LIBRARY_STACKED_QUERY, useListOpen } from '../utils/listOpen'; export interface FlowLibraryModalProps { isOpen: boolean; @@ -116,6 +118,12 @@ export function FlowLibraryModal({ }: FlowLibraryModalProps) { const searchRef = useRef(null); const listRef = useRef(null); + // Phones and tablets stack the list above the preview (space.css): its + // header is a disclosure (open by default, remembered) so collapsing the + // list gives the preview the whole sheet. Side by side on desktop and in + // phone landscape, nothing to collapse. + const stacked = useAfMedia(LIBRARY_STACKED_QUERY); + const [listOpen, toggleListOpen] = useListOpen('library'); const [query, setQuery] = useState(''); const [sortMode, setSortMode] = useState('recent'); @@ -333,6 +341,9 @@ export function FlowLibraryModal({ // onSelect keeps modal state in sync, so skipping here is lossless. const target = e.target as HTMLElement | null; if (target && typeof target.closest === 'function' && target.closest('.af-disclosure')) return; + // The list's own disclosure header is a button: Enter/Space toggle the + // list there (Enter must not load the selected flow). + if (target && typeof target.closest === 'function' && target.closest('.list-disclosure')) return; // Anchor on the selected INSTANCE when known; a STALE row key (its // parent collapsed) falls back to the flow's first visible row rather @@ -623,7 +634,17 @@ export function FlowLibraryModal({
-
+ {stacked ? ( + + ) : null} +
- - + /> {agentSchemaEnabled && ( <> @@ -4612,14 +4606,14 @@ export function PropertiesPanel({ node }: PropertiesPanelProps) { )} - When enabled, the Agent's response output is a JSON string matching this schema. + While Structured output is on, the Agent's response output is a JSON string matching this schema. )} {!agentSchemaEnabled && ( - Disabled: the Agent returns a free-form response string. + Off: the Agent returns a free-form response string. )}
@@ -4945,28 +4939,22 @@ export function PropertiesPanel({ node }: PropertiesPanelProps) {
- - - - When enabled, the schedule re-arms after the branch completes. - + + updateNodeData(node.id, { + eventConfig: { + ...data.eventConfig, + recurrent, + }, + }) + } + />
)} @@ -6053,25 +6041,22 @@ export function PropertiesPanel({ node }: PropertiesPanelProps) { {data.nodeType === 'ask_user' && (
- - - If disabled, user must choose from provided choices - + + updateNodeData(node.id, { + effectConfig: { + ...data.effectConfig, + allowFreeText, + }, + }) + } + />
)} diff --git a/src/components/PublishFlowModal.tsx b/src/components/PublishFlowModal.tsx index 93fecff8..fbd05ecb 100644 --- a/src/components/PublishFlowModal.tsx +++ b/src/components/PublishFlowModal.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import toast from 'react-hot-toast'; +import { AfSwitch } from '@abstractframework/ui-kit'; import { capabilityUnavailable, endpointFromDescriptor, @@ -114,15 +115,17 @@ export function PublishFlowModal({ Leave empty to auto-bump (e.g., 0.0.1 → 0.0.2). - + {result ? (
diff --git a/src/components/RunFlowModal.tsx b/src/components/RunFlowModal.tsx index 3701fab1..d72dcb22 100644 --- a/src/components/RunFlowModal.tsx +++ b/src/components/RunFlowModal.tsx @@ -8,6 +8,9 @@ import { useState, useCallback, useMemo, useEffect, useRef, type DragEvent, type FormEvent, type MouseEvent, type ReactNode } from 'react'; import { useQuery } from '@tanstack/react-query'; import toast from 'react-hot-toast'; +import { randomId } from '@abstractframework/ui-kit'; +import { copyWithFeedback } from '../lib/copy_feedback'; +import { MEDIA_NEEDS_HTTPS, mediaAvailable } from '../lib/secure-context'; import { useFlowStore } from '../hooks/useFlow'; import { useExecutionCapabilities } from '../hooks/useExecutionCapabilities'; import type { ExecutionEvent, ExecutionMetrics, Pin, FlowRunResult, RunSummary } from '../types/flow'; @@ -16,7 +19,9 @@ import { RECALL_LEVEL_OPTIONS } from '../types/recall'; import type { WaitingInfo } from '../hooks/useWebSocket'; import { MarkdownRenderer } from './MarkdownRenderer'; import { AgentSubrunTracePanel } from './AgentSubrunTracePanel'; -import { SteerComposer, SpeculationSelect, type SpeculationValue } from '@abstractframework/ui-kit'; +import { AF_MEDIA, AfSwitch, SteerComposer, SpeculationSelect, useAfMedia, type SpeculationValue } from '@abstractframework/ui-kit'; +import { ListDisclosure } from './ListDisclosure'; +import { useListOpen } from '../utils/listOpen'; import { parseSpeculationInput, withRunSpeculation } from '../utils/speculationControls'; import AfSelect from './inputs/AfSelect'; import AfMultiSelect from './inputs/AfMultiSelect'; @@ -195,11 +200,7 @@ function approvalDismissKey(runId?: string, waitKey?: string): string { } function createRunModalSessionId(): string { - const random = - typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' - ? crypto.randomUUID() - : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; - return `abstractflow-run-${random}`; + return `abstractflow-run-${randomId()}`; } /** Short, human-scannable session/run id (keeps the tail so a copy stays @@ -372,16 +373,7 @@ function durableBlocEndpoint( async function copyTextToClipboard(text: string): Promise { const value = String(text || ''); if (!value) return; - try { - await navigator.clipboard.writeText(value); - } catch { - const el = document.createElement('textarea'); - el.value = value; - document.body.appendChild(el); - el.select(); - document.execCommand('copy'); - document.body.removeChild(el); - } + await copyWithFeedback(value); } function isExecutionHandle(value: unknown): boolean { @@ -2069,6 +2061,12 @@ export function RunFlowModal({ // the "Follow live" pill re-arms it. const [followLive, setFollowLive] = useState(true); const stepsListRef = useRef(null); + // Below 1024 px the steps sit above the step details (space.css): the + // "Execution" header is a disclosure (open by default, remembered) so + // collapsing the steps gives the details the whole sheet. + const stackedExecution = useAfMedia(AF_MEDIA.md); + const [stepsOpen, toggleStepsOpen] = useListOpen('runSteps'); + const stepsHidden = stackedExecution && !stepsOpen; const lastFollowedStepIdRef = useRef(null); const [progressClockMs, setProgressClockMs] = useState(() => Date.now()); const lastAutoTerminalStepIdRef = useRef(null); @@ -5234,8 +5232,8 @@ export function RunFlowModal({ else onPause?.(); }} disabled={isPaused ? !isPaused : !(isRunning && !isWaiting)} - title={isPaused ? 'Resume' : 'Pause'} - aria-label={isPaused ? 'Resume run' : 'Pause run'} + title={isPaused ? 'Resume' : 'Pause'} // state-toggle-lint: allow one-shot action on a running run (not a saved setting) + aria-label={isPaused ? 'Resume run' : 'Pause run'} // state-toggle-lint: allow one-shot action on a running run (not a saved setting) > {isPaused ? '▶' : '⏸'} @@ -5486,18 +5484,7 @@ export function RunFlowModal({ ]); const copyToClipboard = async (value: unknown) => { - const text = formatValue(value); - try { - await navigator.clipboard.writeText(text); - } catch { - // Fallback: best-effort legacy copy - const el = document.createElement('textarea'); - el.value = text; - document.body.appendChild(el); - el.select(); - document.execCommand('copy'); - document.body.removeChild(el); - } + await copyWithFeedback(formatValue(value)); }; const outputPreview = useMemo(() => { @@ -6298,8 +6285,8 @@ export function RunFlowModal({ const startVoiceWaitRecording = useCallback(async () => { if (voiceWaitRecording || voiceWaitBusy || resumeSubmitting) return; - if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) { - setVoiceWaitError('This browser cannot record microphone audio.'); + if (!mediaAvailable()) { + setVoiceWaitError(MEDIA_NEEDS_HTTPS); return; } if (typeof MediaRecorder === 'undefined') { @@ -6491,7 +6478,17 @@ export function RunFlowModal({
-
Execution
+ {stackedExecution ? ( + + ) : ( +
Execution
+ )}
{isRunning ? : null} {runStatusLabel} @@ -6618,6 +6615,7 @@ export function RunFlowModal({ // waiting-like flags (e.g. an unresolved approval on a cancelled // run) — a "Following live" pill on a dead run lies (review // P2-7). + !stepsHidden && !(runSummary && ['completed', 'failed', 'cancelled'].includes(String(runSummary.status || '').toLowerCase())) ? (
{followLive ? ( @@ -6644,7 +6642,7 @@ export function RunFlowModal({
) : null} -
+
{!voiceWaitRecording ? ( @@ -7026,7 +7026,7 @@ export function RunFlowModal({ type="button" className="modal-button primary" onClick={startVoiceWaitRecording} - disabled={voiceWaitBusy || resumeSubmitting} + disabled={voiceWaitBusy || resumeSubmitting || !mediaAvailable()} > Record @@ -7124,10 +7124,14 @@ export function RunFlowModal({ spellCheck={false} />
- +
{!workspaceInputEnabled ? ( @@ -8765,7 +8769,7 @@ export function RunFlowModal({ }} disabled={cancelRequested || (isPaused ? !isPaused : !(isRunning && !isWaiting))} > - {isPaused ? 'Resume' : 'Pause'} + {isPaused ? 'Resume' : 'Pause' /* state-toggle-lint: allow one-shot action on a running run (not a saved setting) */} )} diff --git a/src/components/RunSwitcherDropdown.tsx b/src/components/RunSwitcherDropdown.tsx index 142a4b37..580fa321 100644 --- a/src/components/RunSwitcherDropdown.tsx +++ b/src/components/RunSwitcherDropdown.tsx @@ -5,6 +5,7 @@ */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { RunSummary } from '../types/flow'; +import { copyWithFeedback } from '../lib/copy_feedback'; import { filterRunSummariesByFlowId, mapGatewayRunSummary } from '../utils/gatewayRuns'; import { endpointFromDescriptor, gatewayJson, type GatewayContracts } from '../utils/gatewayClient'; @@ -38,16 +39,7 @@ function formatRunTime(iso?: string | null): string { async function copyText(text: string): Promise { const value = String(text || ''); if (!value) return; - try { - await navigator.clipboard.writeText(value); - } catch { - const el = document.createElement('textarea'); - el.value = value; - document.body.appendChild(el); - el.select(); - document.execCommand('copy'); - document.body.removeChild(el); - } + await copyWithFeedback(value, 'Run id copied'); } export function RunSwitcherDropdown({ diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index cb69e76b..7a7179e5 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -3,8 +3,10 @@ */ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import toast from 'react-hot-toast'; +import { copyWithFeedback } from '../lib/copy_feedback'; import { loadEdgeNotice, useFlowStore } from '../hooks/useFlow'; import { useWebSocket } from '../hooks/useWebSocket'; import { RunFlowModal } from './RunFlowModal'; @@ -528,20 +530,6 @@ export function Toolbar() { } }, []); - const copyTextToClipboard = useCallback(async (text: string) => { - try { - await navigator.clipboard.writeText(text); - } catch { - // Fallback: best-effort legacy copy - const el = document.createElement('textarea'); - el.value = text; - document.body.appendChild(el); - el.select(); - document.execCommand('copy'); - document.body.removeChild(el); - } - }, []); - const showWorkflowFailedToast = useCallback( (fullError: unknown) => { const full = formatValue(fullError) || 'Unknown error'; @@ -555,16 +543,14 @@ export function Toolbar() { style={{ cursor: 'pointer' }} onClick={() => { void (async () => { - await copyTextToClipboard(full); - toast.success('Copied error to clipboard'); + await copyWithFeedback(full, 'Copied error to clipboard'); })(); }} onKeyDown={(e) => { if (e.key !== 'Enter' && e.key !== ' ') return; e.preventDefault(); void (async () => { - await copyTextToClipboard(full); - toast.success('Copied error to clipboard'); + await copyWithFeedback(full, 'Copied error to clipboard'); })(); }} > @@ -576,7 +562,7 @@ export function Toolbar() {
); }, - [copyTextToClipboard, formatValue] + [formatValue] ); async function fetchRunHistory(runId: string): Promise { @@ -1990,168 +1976,177 @@ export function Toolbar() {
- {showNewFlowModal ? ( -
setShowNewFlowModal(false)} role="presentation"> -
e.stopPropagation()}> -

New flow

-

Create a new flow? Any unsaved changes will be lost.

-
- - + {/* The toolbar's dialogs render into , not inside the page +
: a dialog is not part of the banner landmark (screen + readers announced the run window as header content), and the + header's own layout and stacking no longer reach the sheets. */} + {createPortal( + <> + {showNewFlowModal ? ( +
setShowNewFlowModal(false)} role="presentation"> +
e.stopPropagation()}> +

New flow

+

Create a new flow? Any unsaved changes will be lost.

+
+ + +
-
- ) : null} - - - {/* Smart Run Modal */} - {(() => { - const viewing = inspectedRun !== null; - const evs = viewing ? inspectedEvents : executionEvents; - const traces = viewing ? inspectedTraceEvents : traceEvents; - const status = inspectedRun?.status || ''; - const runningLike = - status === 'running' || - (status === 'waiting' && inspectedRun?.wait_reason === 'subworkflow' && !inspectedRun?.paused); - const approvalWaitInfo = viewing ? extractPendingApprovalWait(evs) : null; - const waitingLike = - Boolean(approvalWaitInfo) || - (status === 'waiting' && !inspectedRun?.paused && inspectedRun?.wait_reason !== 'subworkflow'); - const pausedLike = Boolean(inspectedRun?.paused); - const waitingInfo2 = - approvalWaitInfo || - (waitingLike - ? { - prompt: inspectedRun?.prompt || 'Please respond:', - choices: inspectedRun?.choices || [], - allowFreeText: inspectedRun?.allow_free_text !== false, - nodeId: inspectedRun?.current_node || null, - } - : waitingInfo); - - return ( - pauseRun(inspectedRun?.run_id)} - onResumeRun={() => resumeRun(inspectedRun?.run_id)} - onCancelRun={() => - cancelRun(inspectedRun?.run_id).then((confirmed) => { - // Surface the failure path visibly (adversary A1): without this, - // a refused/timed-out cancel wrote an error nobody rendered. - if (!confirmed) toast.error('Cancel not confirmed — the gateway may still be processing it. Try again.'); - }) - } - onSelectRunId={handleSelectRunFromModal} - runSummary={viewing ? inspectedRun : null} - /> - ); - })()} - - setShowRunHistory(false)} - onSelectRun={handleSelectHistoryRun} - /> - - 0 && !flowsQuery.data} - // `flows` ALWAYS contains the ~25 bundled examples (a static glob), so - // gating this on `length === 0` made the error branch unreachable: when - // the session expired and the user opened the library to find their - // work, they saw the shipped examples and no explanation — their own - // flows looked deleted. Surface the fetch error whenever there is one. - error={flowsQuery.error} - onClose={() => setShowFlowLibrary(false)} - onRefresh={() => flowsQuery.refetch()} - onLoadFlow={handleLoadFlow} - onRenameFlow={handleRenameFlow} - onUpdateDescription={handleUpdateDescription} - onUpdateInterfaces={handleUpdateInterfaces} - onUpdateAutomationDefaults={handleUpdateAutomationDefaults} - gatewayContracts={gatewayContracts} - onDuplicateFlow={handleDuplicateFlow} - onDeleteFlow={handleDeleteFlow} - /> - - setShowPublishModal(false)} - /> - - setShowLifecycleModal(false)} - /> - - setShowModelResidency(false)} - /> - - {/* In-app confirmation modal (browser dialogs are banned in this UI). - Rendered last so it stacks above any other open modal. */} - {confirmPrompt ? ( -
setConfirmPrompt(null)} role="presentation"> -
e.stopPropagation()}> -

{confirmPrompt.title}

-

{confirmPrompt.message}

-
- - + ) : null} + + + {/* Smart Run Modal */} + {(() => { + const viewing = inspectedRun !== null; + const evs = viewing ? inspectedEvents : executionEvents; + const traces = viewing ? inspectedTraceEvents : traceEvents; + const status = inspectedRun?.status || ''; + const runningLike = + status === 'running' || + (status === 'waiting' && inspectedRun?.wait_reason === 'subworkflow' && !inspectedRun?.paused); + const approvalWaitInfo = viewing ? extractPendingApprovalWait(evs) : null; + const waitingLike = + Boolean(approvalWaitInfo) || + (status === 'waiting' && !inspectedRun?.paused && inspectedRun?.wait_reason !== 'subworkflow'); + const pausedLike = Boolean(inspectedRun?.paused); + const waitingInfo2 = + approvalWaitInfo || + (waitingLike + ? { + prompt: inspectedRun?.prompt || 'Please respond:', + choices: inspectedRun?.choices || [], + allowFreeText: inspectedRun?.allow_free_text !== false, + nodeId: inspectedRun?.current_node || null, + } + : waitingInfo); + + return ( + pauseRun(inspectedRun?.run_id)} + onResumeRun={() => resumeRun(inspectedRun?.run_id)} + onCancelRun={() => + cancelRun(inspectedRun?.run_id).then((confirmed) => { + // Surface the failure path visibly (adversary A1): without this, + // a refused/timed-out cancel wrote an error nobody rendered. + if (!confirmed) toast.error('Cancel not confirmed — the gateway may still be processing it. Try again.'); + }) + } + onSelectRunId={handleSelectRunFromModal} + runSummary={viewing ? inspectedRun : null} + /> + ); + })()} + + setShowRunHistory(false)} + onSelectRun={handleSelectHistoryRun} + /> + + 0 && !flowsQuery.data} + // `flows` ALWAYS contains the ~25 bundled examples (a static glob), so + // gating this on `length === 0` made the error branch unreachable: when + // the session expired and the user opened the library to find their + // work, they saw the shipped examples and no explanation — their own + // flows looked deleted. Surface the fetch error whenever there is one. + error={flowsQuery.error} + onClose={() => setShowFlowLibrary(false)} + onRefresh={() => flowsQuery.refetch()} + onLoadFlow={handleLoadFlow} + onRenameFlow={handleRenameFlow} + onUpdateDescription={handleUpdateDescription} + onUpdateInterfaces={handleUpdateInterfaces} + onUpdateAutomationDefaults={handleUpdateAutomationDefaults} + gatewayContracts={gatewayContracts} + onDuplicateFlow={handleDuplicateFlow} + onDeleteFlow={handleDeleteFlow} + /> + + setShowPublishModal(false)} + /> + + setShowLifecycleModal(false)} + /> + + setShowModelResidency(false)} + /> + + {/* In-app confirmation modal (browser dialogs are banned in this UI). + Rendered last so it stacks above any other open modal. */} + {confirmPrompt ? ( +
setConfirmPrompt(null)} role="presentation"> +
e.stopPropagation()}> +

{confirmPrompt.title}

+

{confirmPrompt.message}

+
+ + +
-
- ) : null} + ) : null} + , + document.body, + )} ); } diff --git a/src/components/assistant/AssistantStatusCard.tsx b/src/components/assistant/AssistantStatusCard.tsx index 57ffd79e..d2c68ead 100644 --- a/src/components/assistant/AssistantStatusCard.tsx +++ b/src/components/assistant/AssistantStatusCard.tsx @@ -12,6 +12,7 @@ import { } from './assistantActivity'; import { formatTokenCount } from '../../utils/plannerUsage'; import { IconChevron, IconCopy } from './icons'; +import { copyWithFeedback } from '../../lib/copy_feedback'; interface AssistantStatusCardProps { workingStatus: WorkingStatus; @@ -177,13 +178,13 @@ export function AssistantStatusCard({ onClick={(event) => { event.preventDefault(); event.stopPropagation(); - void navigator.clipboard.writeText(entry.detail || ''); + void copyWithFeedback(entry.detail || ''); }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); - void navigator.clipboard.writeText(entry.detail || ''); + void copyWithFeedback(entry.detail || ''); } }} title="Copy full payload to clipboard" diff --git a/src/components/nodes/BaseNode.tsx b/src/components/nodes/BaseNode.tsx index abe1d9ca..44d01d7b 100644 --- a/src/components/nodes/BaseNode.tsx +++ b/src/components/nodes/BaseNode.tsx @@ -11,6 +11,7 @@ import { useQuery } from '@tanstack/react-query'; import { Handle, Position, NodeProps, useUpdateNodeInternals } from 'reactflow'; import { clsx } from 'clsx'; import toast from 'react-hot-toast'; +import { randomId } from '@abstractframework/ui-kit'; import type { FlowNodeData, JsonValue, Pin, PinType, VisualFlow } from '../../types/flow'; import { getBundledFlow } from '../../utils/bundledFlows'; import { PIN_COLORS, isEntryNodeType } from '../../types/flow'; @@ -3810,9 +3811,7 @@ export const BaseNode = memo(function BaseNode({ const existingCases = data.switchConfig?.cases ?? []; const newId = - typeof crypto !== 'undefined' && 'randomUUID' in crypto - ? (crypto.randomUUID() as string).slice(0, 8) - : `c${Date.now().toString(16)}${Math.random().toString(16).slice(2, 6)}`; + randomId().slice(0, 8); const nextCases = [...existingCases, { id: newId, value: '' }]; diff --git a/src/components/stateToggles.test.tsx b/src/components/stateToggles.test.tsx new file mode 100644 index 00000000..41d84171 --- /dev/null +++ b/src/components/stateToggles.test.tsx @@ -0,0 +1,122 @@ +// On/off settings are kit switches labelled by the feature (state-toggles +// rule, 2026-09-30): the switch position IS the state, highlighted when on, +// never a verb label ("Enabled"/"Disabled", "Pause"/"Resume" swaps) and never +// a bare checkbox for a saved node setting or a dialog option. +// +// The package ships no DOM for tests, so the components render with +// react-dom/server and the assertions read the markup. +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { findVerbToggleLabels } from '@abstractframework/ui-kit'; +import { describe, expect, it } from 'vitest'; +import type { Node } from 'reactflow'; + +import { createNodeData, getNodeTemplate } from '../types/nodes'; +import type { FlowNodeData, NodeType } from '../types/flow'; +import { PropertiesPanel } from './PropertiesPanel'; +import { PublishFlowModal } from './PublishFlowModal'; + +const SRC = resolve(__dirname, '..'); + +function nodeOf(type: NodeType, patch: Partial = {}): Node { + const template = getNodeTemplate(type); + if (!template) throw new Error(`no template for ${type}`); + return { id: `n-${type}`, type: 'custom', position: { x: 0, y: 0 }, data: { ...createNodeData(template), ...patch } }; +} + +function renderPanel(node: Node): string { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } }); + return renderToStaticMarkup(createElement(QueryClientProvider, { client }, createElement(PropertiesPanel, { node }))); +} + +/** The