From 1c0189bab3e4e2c3b81f3539fc6fd2ccf90090ea Mon Sep 17 00:00:00 2001 From: Nasim Akhtar Date: Wed, 28 Oct 2026 03:58:48 +0530 Subject: [PATCH] feat(repo): one click one run with pending and settled actions, guardAction and an extend tab --- apps/design/src/demos/blocks/billing.demo.tsx | 10 ++ .../src/demos/blocks/generative-ui.demo.tsx | 7 +- .../src/demos/blocks/generative-ui/catalog.ts | 51 +++----- .../src/demos/blocks/generative-ui/presets.ts | 13 +- apps/design/src/demos/blocks/keys.demo.tsx | 10 ++ .../guides/changelog/changelog-entries.ts | 13 ++ .../generative-ui/docs/extend-guide.tsx | 123 ++++++++++++++++++ .../docs/generative-docs-panel.tsx | 5 + .../generative-ui/generative-ui-page.tsx | 1 + .../session/use-generative-session.ts | 3 +- .../src/json-render/billing-spec.ts | 4 +- .../src/json-render/json-render-demo.tsx | 3 +- .../src/json-render/registry.tsx | 45 +++---- packages/blocks/README.md | 4 +- packages/blocks/package.json | 2 +- packages/blocks/src/actions/block-actions.tsx | 30 ++++- .../actions/tests/pending-settled.test.tsx | 81 ++++++++++++ packages/blocks/src/billing/plans.tsx | 18 ++- packages/blocks/src/keys/keys.tsx | 22 ++-- packages/blocks/src/types.ts | 4 + packages/json-render/README.md | 49 ++++++- packages/json-render/package.json | 2 +- .../json-render/src/actions/guard-action.ts | 34 +++++ packages/json-render/src/index.ts | 13 +- packages/json-render/src/registry/blocks.tsx | 35 ++++- .../json-render/src/registry/conversions.ts | 18 ++- .../src/tests/guard-action.test.tsx | 55 ++++++++ 27 files changed, 552 insertions(+), 103 deletions(-) create mode 100644 apps/design/src/shell/guides/generative-ui/docs/extend-guide.tsx create mode 100644 packages/blocks/src/actions/tests/pending-settled.test.tsx create mode 100644 packages/json-render/src/actions/guard-action.ts create mode 100644 packages/json-render/src/tests/guard-action.test.tsx diff --git a/apps/design/src/demos/blocks/billing.demo.tsx b/apps/design/src/demos/blocks/billing.demo.tsx index 5d33b659..23621147 100644 --- a/apps/design/src/demos/blocks/billing.demo.tsx +++ b/apps/design/src/demos/blocks/billing.demo.tsx @@ -87,6 +87,16 @@ export default defineDemo({ { name: "Plans.actionFor", type: "(plan: PlanOffer) => ReactNode" }, { name: "Plans.actions", type: "BlockAction[]", defaultValue: "data instead of actionFor" }, { name: "Plans.onAction", type: "(actionId: string, plan: PlanOffer) => void" }, + { + name: "Plans.pending", + type: "{ actionId, target }", + defaultValue: "locks every action, spinner on the plan named target", + }, + { + name: "Plans.settled", + type: "{ actionId, target, label }", + defaultValue: "shows label on target, locks the rest", + }, ], examples: { ChoosePlan: { diff --git a/apps/design/src/demos/blocks/generative-ui.demo.tsx b/apps/design/src/demos/blocks/generative-ui.demo.tsx index 8dc88bac..49bbdb24 100644 --- a/apps/design/src/demos/blocks/generative-ui.demo.tsx +++ b/apps/design/src/demos/blocks/generative-ui.demo.tsx @@ -19,7 +19,7 @@ export default defineDemo({ controls: { spec: select(presetNames, "over-quota") }, playground: ({ spec }) => , code: () => - 'import { defineCatalog } from "@json-render/core";\nimport { defineRegistry } from "@json-render/react";\nimport { schema } from "@json-render/react/schema";\nimport { locusComponents, locusRenderers } from "@locusgraph/json-render";\nimport { useApprovalGate } from "@locusgraph/loop";\n\nconst catalog = defineCatalog(schema, {\n components: { ...locusComponents },\n actions: { pickedAction: { description: "Run the action at /picked" } },\n});\n\nconst { registry, handlers } = defineRegistry(catalog, {\n components: { ...locusRenderers },\n actions: {\n pickedAction: async (_params, setState, state) => {\n const decision = await gate.ask({ toolName: state.picked.actionId, risk: "write" });\n if (decision.approved) await run(state.picked);\n },\n },\n});\n\nconst systemPrompt = catalog.prompt();', + 'import { defineCatalog } from "@json-render/core";\nimport { defineRegistry } from "@json-render/react";\nimport { schema } from "@json-render/react/schema";\nimport { guardAction, locusComponents, locusRenderers } from "@locusgraph/json-render";\n\nconst catalog = defineCatalog(schema, {\n components: { ...locusComponents },\n actions: { pickedAction: { description: "Run the action at /picked" } },\n});\n\nconst { registry, handlers } = defineRegistry(catalog, {\n components: { ...locusRenderers },\n actions: {\n pickedAction: guardAction(async (picked) => {\n const decision = await gate.ask({ toolName: picked.actionId, risk: "write", input: picked });\n if (!decision.approved) return null;\n await run(picked);\n return `Done: ${picked.actionId} ${picked.target}`;\n }),\n },\n});\n\nconst systemPrompt = catalog.prompt();', props: [ { name: "locusComponents", @@ -28,6 +28,11 @@ export default defineDemo({ { name: "locusRenderers", type: "registry components for the same names" }, { name: "TopUp emits", type: "buy, after writing /selectedPackId" }, { name: "Plans and Keys emit", type: "action, after writing /picked = { actionId, target }" }, + { + name: "guardAction(run)", + type: "action handler: one run at a time, /pending while it runs, /settled = { label } after", + }, + { name: "/locked", type: "true freezes a turn, such as an older one in a chat" }, ], examples: { CatalogPrompt: { diff --git a/apps/design/src/demos/blocks/generative-ui/catalog.ts b/apps/design/src/demos/blocks/generative-ui/catalog.ts index aa9835d5..e45982c7 100644 --- a/apps/design/src/demos/blocks/generative-ui/catalog.ts +++ b/apps/design/src/demos/blocks/generative-ui/catalog.ts @@ -1,13 +1,11 @@ import { defineCatalog } from "@json-render/core"; import { defineRegistry } from "@json-render/react"; import { schema } from "@json-render/react/schema"; -import { locusComponents, locusRenderers } from "@locusgraph/json-render"; +import { guardAction, locusComponents, locusRenderers } from "@locusgraph/json-render"; import type { ApprovalDecision, ApprovalRequest } from "@locusgraph/loop"; export type AskApproval = (request: ApprovalRequest) => Promise; -type Picked = { actionId: string; target: string }; - export const catalog = defineCatalog(schema, { components: { ...locusComponents }, actions: { @@ -16,38 +14,29 @@ export const catalog = defineCatalog(schema, { }, }); -function outcome(decision: ApprovalDecision, done: string) { - return decision.approved ? `${done}, approved by ${decision.by}` : `Denied by ${decision.by}`; +const doneLabels: Record string> = { + buy: (pack) => `Checkout opened for ${pack}`, + upgrade: (plan) => `Moved to ${plan}`, + rotate: (key) => `Rotated ${key}`, + revoke: (key) => `Revoked ${key}`, +}; + +function doneLabel(actionId: string, target: string) { + return doneLabels[actionId]?.(target) ?? `${actionId} ran on ${target}`; } export function createGatedRegistry(ask: AskApproval) { + const runAfterApproval = guardAction(async ({ actionId, target }) => { + const decision = await ask({ + toolName: actionId === "buy" ? "buy_credits" : actionId, + risk: actionId === "revoke" ? "destructive" : "write", + input: { target }, + title: `${actionId} ${target}`, + }); + return decision.approved ? `${doneLabel(actionId, target)} · ${decision.by}` : null; + }); return defineRegistry(catalog, { components: { ...locusRenderers }, - actions: { - buyCredits: async (_params, setState, state) => { - const pack = String(state.selectedPackId); - const decision = await ask({ - toolName: "buy_credits", - risk: "write", - input: { pack }, - title: `Buy ${pack}`, - actionLabel: "Open checkout", - }); - setState((previous) => ({ ...previous, lastAction: outcome(decision, `Bought ${pack}`) })); - }, - pickedAction: async (_params, setState, state) => { - const { actionId, target } = state.picked as Picked; - const decision = await ask({ - toolName: actionId, - risk: actionId === "revoke" ? "destructive" : "write", - input: { target }, - title: `${actionId} ${target}`, - }); - setState((previous) => ({ - ...previous, - lastAction: outcome(decision, `${actionId} ran on ${target}`), - })); - }, - }, + actions: { buyCredits: runAfterApproval, pickedAction: runAfterApproval }, }); } diff --git a/apps/design/src/demos/blocks/generative-ui/presets.ts b/apps/design/src/demos/blocks/generative-ui/presets.ts index 53d893d3..55fa9cb7 100644 --- a/apps/design/src/demos/blocks/generative-ui/presets.ts +++ b/apps/design/src/demos/blocks/generative-ui/presets.ts @@ -29,19 +29,20 @@ export const sampleState = { ], selectedPackId: null, picked: null, - lastAction: null, + pending: null, + settled: null, }; const lastAction = { type: "Notice", - props: { tone: "success", title: { $state: "/lastAction" }, description: null }, - visible: { $state: "/lastAction" }, + props: { tone: "success", title: { $state: "/settled/label" }, description: null }, + visible: { $state: "/settled" }, }; export const overQuotaSpec: Spec = { root: "page", elements: { - page: { type: "Stack", props: { gap: "md" }, children: ["notice", "usage", "plans", "done"] }, + page: { type: "Stack", props: { gap: "md" }, children: ["notice", "usage", "plans"] }, notice: { type: "Notice", props: { tone: "warning", title: "You're 450 events over this month", description: null }, @@ -56,7 +57,6 @@ export const overQuotaSpec: Spec = { }, on: { action: { action: "pickedAction" } }, }, - done: lastAction, }, }; @@ -84,7 +84,7 @@ export const creditsSpec: Spec = { export const keysSpec: Spec = { root: "page", elements: { - page: { type: "Stack", props: { gap: "md" }, children: ["keys", "done"] }, + page: { type: "Stack", props: { gap: "md" }, children: ["keys"] }, keys: { type: "Keys", props: { @@ -96,7 +96,6 @@ export const keysSpec: Spec = { }, on: { action: { action: "pickedAction" } }, }, - done: lastAction, }, }; diff --git a/apps/design/src/demos/blocks/keys.demo.tsx b/apps/design/src/demos/blocks/keys.demo.tsx index 049cc34e..ccf3544d 100644 --- a/apps/design/src/demos/blocks/keys.demo.tsx +++ b/apps/design/src/demos/blocks/keys.demo.tsx @@ -91,6 +91,16 @@ export default defineDemo({ { name: "Keys.keys", type: "ApiKeyRow[]" }, { name: "Keys.actions", type: "((key: ApiKeyRow) => ReactNode) | BlockAction[]" }, { name: "Keys.onAction", type: "(actionId: string, key: ApiKeyRow) => void" }, + { + name: "Keys.pending", + type: "{ actionId, target }", + defaultValue: "locks every action, spinner on the key whose id is target", + }, + { + name: "Keys.settled", + type: "{ actionId, target, label }", + defaultValue: "shows label on target, locks the rest", + }, { name: "Keys.highlightedId", type: "string", defaultValue: "flashes a just-created key" }, { name: "KeysEmpty.newKeyHref", type: "string", defaultValue: "omit for read-only roles" }, ], diff --git a/apps/design/src/shell/guides/changelog/changelog-entries.ts b/apps/design/src/shell/guides/changelog/changelog-entries.ts index e8146e72..90a436aa 100644 --- a/apps/design/src/shell/guides/changelog/changelog-entries.ts +++ b/apps/design/src/shell/guides/changelog/changelog-entries.ts @@ -10,6 +10,19 @@ export const changelogEntries: ChangelogEntry[] = [ { id: "next", date: "Next release", + title: "One click, one run", + packages: ["blocks 0.8.0", "json-render 0.2.0"], + changes: [ + "Plans and Keys take pending, which locks every action and spins the one that runs.", + "Plans and Keys take settled, which shows the outcome in place of the buttons.", + "guardAction runs one action at a time and writes /pending, then /settled, to state.", + "/locked freezes an older turn so its buttons cannot fire again.", + "Generative UI page: an Extend tab for adding, replacing and removing catalog components.", + ], + }, + { + id: "2026-10-28", + date: "2026-10-28", title: "Human in the loop, IBM Plex and a quieter light theme", packages: [ "tokens 0.5.0", diff --git a/apps/design/src/shell/guides/generative-ui/docs/extend-guide.tsx b/apps/design/src/shell/guides/generative-ui/docs/extend-guide.tsx new file mode 100644 index 00000000..6bd0ccf7 --- /dev/null +++ b/apps/design/src/shell/guides/generative-ui/docs/extend-guide.tsx @@ -0,0 +1,123 @@ +import type * as React from "react"; +import { CodeBlock } from "../../../preview/code/code-block"; + +const addComponentCode = `import { defineCatalog } from "@json-render/core"; +import { defineRegistry } from "@json-render/react"; +import { schema } from "@json-render/react/schema"; +import { Badge } from "@locusgraph/components"; +import { locusComponents, locusRenderers } from "@locusgraph/json-render"; +import { z } from "zod"; + +const catalog = defineCatalog(schema, { + components: { + ...locusComponents, + StatusBadge: { + props: z.object({ + label: z.string(), + tone: z.enum(["success", "warning"]).nullable(), + }), + description: "A short status word next to a block", + }, + }, + actions: {}, +}); + +const { registry } = defineRegistry(catalog, { + components: { + ...locusRenderers, + StatusBadge: ({ props }) => ( + {props.label} + ), + }, +});`; + +const replaceCode = `components: { ...locusRenderers, Wallet: MyWallet }`; + +const removeCode = `const { Keys, ...catalogWithoutKeys } = locusComponents; +const { Keys: _keys, ...renderersWithoutKeys } = locusRenderers;`; + +const actionCode = `actions: { + archive: guardAction(async (picked) => { + const decision = await gate.ask({ + toolName: "archive", + risk: "write", + input: picked, + }); + if (!decision.approved) return null; + await archive(picked.target); + return \`Archived \${picked.target}\`; + }), +}`; + +const lockTurnCode = `store.update({ "/locked": true });`; + +function Step({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function Note({ children }: { children: React.ReactNode }) { + return

{children}

; +} + +function Snippet({ code }: { code: string }) { + return ( + + ); +} + +export function ExtendGuide() { + return ( + <> + + Spread locusComponents into your catalog and locusRenderers into + your registry, then add your own entries under the same name in both. + + + + + catalog.prompt() now lists it for the model. Build renderers from{" "} + @locusgraph/components so they keep the Locus Design look. + + + + + The later key wins. Keep the props, or replace the catalog entry too. + + + + + + + + guardAction runs one click at a time. While it runs, /pending{" "} + locks every button and spins the one clicked. The label you return goes to{" "} + /settled: the block shows it in place of the buttons and the turn stays + frozen. Return null, as on a denial, to leave the turn open. + + + + + + Give each turn its own state store. When the next turn arrives, lock the older one so its + buttons cannot fire again. + + + +
    +
  • Nullable props, not optional: models write null more reliably.
  • +
  • Bind numbers to state with $state; the model picks the layout only.
  • +
  • Each description says when to use the component. The model reads it.
  • +
+
+ + ); +} diff --git a/apps/design/src/shell/guides/generative-ui/docs/generative-docs-panel.tsx b/apps/design/src/shell/guides/generative-ui/docs/generative-docs-panel.tsx index 7aa4766b..28b37277 100644 --- a/apps/design/src/shell/guides/generative-ui/docs/generative-docs-panel.tsx +++ b/apps/design/src/shell/guides/generative-ui/docs/generative-docs-panel.tsx @@ -2,6 +2,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@locusgraph/components import type * as React from "react"; import { PromptTab } from "../../theming/docs/prompt-tab"; import { CatalogList } from "./catalog-list"; +import { ExtendGuide } from "./extend-guide"; import { SpecEditor } from "./spec-editor"; type GenerativeDocsPanelProps = { @@ -20,6 +21,7 @@ export function GenerativeDocsPanel({ editor, prompt }: GenerativeDocsPanelProps Spec Catalog Prompt + Extend @@ -34,6 +36,9 @@ export function GenerativeDocsPanel({ editor, prompt }: GenerativeDocsPanelProps note="catalog.prompt() writes this from the catalog. Send it as the system prompt." /> + + + ); } diff --git a/apps/design/src/shell/guides/generative-ui/generative-ui-page.tsx b/apps/design/src/shell/guides/generative-ui/generative-ui-page.tsx index d39a6910..bef2fc16 100644 --- a/apps/design/src/shell/guides/generative-ui/generative-ui-page.tsx +++ b/apps/design/src/shell/guides/generative-ui/generative-ui-page.tsx @@ -36,6 +36,7 @@ export function GenerativeUiPage() { setPrompt(sample.prompt); setSource(formatSpec(sample.spec)); setLastGood(sample.spec); + session.startTurn(); if (replay) setRunId((current) => current + 1); }; const editSource = (next: string) => { diff --git a/apps/design/src/shell/guides/generative-ui/session/use-generative-session.ts b/apps/design/src/shell/guides/generative-ui/session/use-generative-session.ts index 137f5d0b..043c0cce 100644 --- a/apps/design/src/shell/guides/generative-ui/session/use-generative-session.ts +++ b/apps/design/src/shell/guides/generative-ui/session/use-generative-session.ts @@ -54,7 +54,8 @@ export function useGenerativeSession(writeGate: WriteGate) { const log = [...gate.log, ...autoLog].sort( (first, second) => decidedAtMs(first) - decidedAtMs(second) ); - return { gate, store, registry, actionHandlers, log }; + const startTurn = () => store.update({ "/picked": null, "/pending": null, "/settled": null }); + return { gate, store, registry, actionHandlers, log, startTurn }; } export type GenerativeSession = ReturnType; diff --git a/apps/example-react/src/json-render/billing-spec.ts b/apps/example-react/src/json-render/billing-spec.ts index f484e318..da723513 100644 --- a/apps/example-react/src/json-render/billing-spec.ts +++ b/apps/example-react/src/json-render/billing-spec.ts @@ -59,8 +59,8 @@ export const billingSpec: Spec = { }, "last-action": { type: "Notice", - props: { tone: "success", title: { $state: "/lastAction" }, description: null }, - visible: { $state: "/lastAction" }, + props: { tone: "success", title: { $state: "/settled/label" }, description: null }, + visible: { $state: "/settled" }, }, }, }; diff --git a/apps/example-react/src/json-render/json-render-demo.tsx b/apps/example-react/src/json-render/json-render-demo.tsx index 6777a001..e1a4e29e 100644 --- a/apps/example-react/src/json-render/json-render-demo.tsx +++ b/apps/example-react/src/json-render/json-render-demo.tsx @@ -19,7 +19,8 @@ function createBillingStore() { keys, selectedPackId: null, picked: null, - lastAction: null, + pending: null, + settled: null, }); } diff --git a/apps/example-react/src/json-render/registry.tsx b/apps/example-react/src/json-render/registry.tsx index 7c29d3fd..e88d4e9a 100644 --- a/apps/example-react/src/json-render/registry.tsx +++ b/apps/example-react/src/json-render/registry.tsx @@ -1,44 +1,31 @@ import { defineRegistry } from "@json-render/react"; -import { locusRenderers } from "@locusgraph/json-render"; +import { guardAction, locusRenderers } from "@locusgraph/json-render"; import type { ApprovalDecision, ApprovalRequest } from "@locusgraph/loop"; -import { buyCreditsApproval, keyApproval, type Picked, planApproval } from "./approvals"; +import { buyCreditsApproval, keyApproval, planApproval } from "./approvals"; import { catalog } from "./catalog"; export type AskApproval = (request: ApprovalRequest) => Promise; -function outcome(decision: ApprovalDecision, done: string) { - if (decision.approved) return `${done}, approved by ${decision.by}`; - return `Not run, denied by ${decision.by}${decision.reason ? `: ${decision.reason}` : ""}`; +function approvedLabel(decision: ApprovalDecision, done: string) { + return decision.approved ? `${done}, approved by ${decision.by}` : null; } export function createBillingRegistry(ask: AskApproval) { return defineRegistry(catalog, { components: { ...locusRenderers }, actions: { - buyCredits: async (_params, setState, state) => { - const packId = String(state.selectedPackId); - const decision = await ask(buyCreditsApproval(packId)); - setState((previous) => ({ - ...previous, - lastAction: outcome(decision, `Checkout opened for ${packId}`), - })); - }, - planAction: async (_params, setState, state) => { - const picked = state.picked as Picked; - const decision = await ask(planApproval(picked)); - setState((previous) => ({ - ...previous, - lastAction: outcome(decision, `Plan changed to ${picked.target}`), - })); - }, - keyAction: async (_params, setState, state) => { - const picked = state.picked as Picked; - const decision = await ask(keyApproval(picked)); - setState((previous) => ({ - ...previous, - lastAction: outcome(decision, `${picked.actionId} ran on ${picked.target}`), - })); - }, + buyCredits: guardAction(async (picked) => + approvedLabel( + await ask(buyCreditsApproval(picked.target)), + `Checkout opened for ${picked.target}` + ) + ), + planAction: guardAction(async (picked) => + approvedLabel(await ask(planApproval(picked)), `Plan changed to ${picked.target}`) + ), + keyAction: guardAction(async (picked) => + approvedLabel(await ask(keyApproval(picked)), `${picked.actionId} ran on ${picked.target}`) + ), }, }); } diff --git a/packages/blocks/README.md b/packages/blocks/README.md index ee55ed20..a6ed5e20 100644 --- a/packages/blocks/README.md +++ b/packages/blocks/README.md @@ -22,8 +22,8 @@ pnpm add @locusgraph/blocks @locusgraph/components | `UsageAlert` | `balanceMicroUsd`, `quotas`, `paidPlan`, `buyCreditsHref`, `upgradeHref`, `linkComponent`. Renders nothing when credits are fine and no quota is over | | `Wallet` | `balanceMicroUsd`, `debtMicroUsd`, `grants`. A Rule Grid cell | | `TopUp` | `packs`, `onBuy(packId)`, `pending`. A Rule Grid cell | -| `Plans` | `plans`, `actionFor(plan)` for plans that are not current, or `actions` as data (`BlockAction[]`) with `onAction(actionId, plan)` | -| `Keys` | `keys`, `actions(key)` or `actions` as data with `onAction(actionId, key)`, `highlightedId` to flash a just-created key. Dates render in the viewer's time zone after hydration | +| `Plans` | `plans`, `actionFor(plan)` for plans that are not current, or `actions` as data (`BlockAction[]`) with `onAction(actionId, plan)`. `pending` locks every action and spins the one running; `settled` shows its `label` on the target in place of the buttons | +| `Keys` | `keys`, `actions(key)` or `actions` as data with `onAction(actionId, key)`, `highlightedId` to flash a just-created key, `pending` and `settled` as for `Plans`, with the key id as `target`. Dates render in the viewer's time zone after hydration | | `LocalDate` | `value` (ISO string): UTC on the server, the viewer's time zone after hydration | | `KeysEmpty` | `newKeyHref` (omit for read-only roles), `linkComponent` | diff --git a/packages/blocks/package.json b/packages/blocks/package.json index a0558394..eec7fae2 100644 --- a/packages/blocks/package.json +++ b/packages/blocks/package.json @@ -1,6 +1,6 @@ { "name": "@locusgraph/blocks", - "version": "0.7.2", + "version": "0.8.0", "description": "Product sections built from @locusgraph/components: usage meters and alerts, wallet, credit packs, plan options and API keys.", "license": "MIT", "repository": { diff --git a/packages/blocks/src/actions/block-actions.tsx b/packages/blocks/src/actions/block-actions.tsx index 966a5a7e..6fb9c721 100644 --- a/packages/blocks/src/actions/block-actions.tsx +++ b/packages/blocks/src/actions/block-actions.tsx @@ -1,16 +1,26 @@ "use client"; -import { Button, cn } from "@locusgraph/components"; +import { Button, cn, Spinner } from "@locusgraph/components"; +import { Check as CheckIcon } from "@phosphor-icons/react/ssr"; import type { BlockAction, BlockActionVariant } from "../types"; type BlockActionsProps = { actions: BlockAction[]; defaultVariant: BlockActionVariant; onAction?: (actionId: string) => void; + pendingActionId?: string; + locked?: boolean; className?: string; }; -export function BlockActions({ actions, defaultVariant, onAction, className }: BlockActionsProps) { +export function BlockActions({ + actions, + defaultVariant, + onAction, + pendingActionId, + locked = false, + className, +}: BlockActionsProps) { return (
{actions.map((action) => ( @@ -19,13 +29,27 @@ export function BlockActions({ actions, defaultVariant, onAction, className }: B type="button" size="sm" variant={action.variant ?? defaultVariant} - disabled={action.disabled} + disabled={action.disabled || locked} + aria-busy={action.id === pendingActionId || undefined} data-action-id={action.id} onClick={() => onAction?.(action.id)} > + {action.id === pendingActionId &&
); } + +export function BlockActionOutcome({ label, className }: { label: string; className?: string }) { + return ( +

+ + {label} +

+ ); +} diff --git a/packages/blocks/src/actions/tests/pending-settled.test.tsx b/packages/blocks/src/actions/tests/pending-settled.test.tsx new file mode 100644 index 00000000..10fa0841 --- /dev/null +++ b/packages/blocks/src/actions/tests/pending-settled.test.tsx @@ -0,0 +1,81 @@ +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { Keys, Plans } from "../../index"; +import type { ApiKeyRow, PlanOffer } from "../../types"; +import { installDom } from "./dom"; + +const plans: PlanOffer[] = [ + { name: "Free", price: "$0", points: ["A free tier"], current: true }, + { name: "Pro", price: "$40.00 per month", points: ["Higher quotas"], current: false }, + { name: "Team", price: "$90.00 per month", points: ["Seats"], current: false }, +]; + +const keys: ApiKeyRow[] = ["k1", "k2"].map((id) => ({ + id, + name: id, + maskedKey: `lg_…${id}`, + productLabel: "LocusGraph", + createdBy: "You", + createdAt: null, + expiresAt: null, +})); + +let root: Root | undefined; +let container: HTMLElement; + +async function mount(node: ReactNode) { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => root?.render(node)); +} + +function buttonsFor(actionId: string) { + return [...container.querySelectorAll(`[data-action-id="${actionId}"]`)]; +} + +beforeAll(installDom); + +afterEach(async () => { + await act(async () => root?.unmount()); + container.remove(); +}); + +describe("pending", () => { + it("disables every action and marks the one that is running", async () => { + const onAction = vi.fn(); + await mount( + + ); + const [pro, team] = buttonsFor("upgrade"); + expect(pro?.disabled).toBe(true); + expect(team?.disabled).toBe(true); + expect(team?.getAttribute("aria-busy")).toBe("true"); + expect(pro?.hasAttribute("aria-busy")).toBe(false); + await act(async () => pro?.click()); + expect(onAction).not.toHaveBeenCalled(); + }); +}); + +describe("settled", () => { + it("shows the outcome on the target and locks the other actions", async () => { + await mount( + + ); + const outcome = container.querySelector('[data-slot="block-action-outcome"]'); + expect(outcome?.textContent).toBe("Revoked"); + const remaining = buttonsFor("revoke"); + expect(remaining).toHaveLength(1); + expect(remaining[0]?.disabled).toBe(true); + }); +}); diff --git a/packages/blocks/src/billing/plans.tsx b/packages/blocks/src/billing/plans.tsx index d4f0e5ed..021f570c 100644 --- a/packages/blocks/src/billing/plans.tsx +++ b/packages/blocks/src/billing/plans.tsx @@ -6,8 +6,8 @@ import { RuleGridTitle, } from "@locusgraph/components"; import type * as React from "react"; -import { BlockActions } from "../actions/block-actions"; -import type { BlockAction, PlanOffer } from "../types"; +import { BlockActionOutcome, BlockActions } from "../actions/block-actions"; +import type { BlockAction, PendingBlockAction, PlanOffer, SettledBlockAction } from "../types"; import { PlanPrice } from "./plan-price"; type PlansProps = { @@ -15,26 +15,34 @@ type PlansProps = { actionFor?: (plan: PlanOffer) => React.ReactNode; actions?: BlockAction[]; onAction?: (actionId: string, plan: PlanOffer) => void; + pending?: PendingBlockAction; + settled?: SettledBlockAction; }; -function planAction({ actionFor, actions, onAction }: Omit, plan: PlanOffer) { +function planAction( + { actionFor, actions, onAction, pending, settled }: Omit, + plan: PlanOffer +) { if (plan.current) return null; if (actionFor) return actionFor(plan); + if (settled?.target === plan.name) return ; if (!actions?.length) return null; return ( onAction(actionId, plan))} /> ); } -export function Plans({ plans, actionFor, actions, onAction }: PlansProps) { +export function Plans({ plans, actionFor, actions, onAction, pending, settled }: PlansProps) { return ( = 3 ? 3 : 2}> {plans.map((plan) => { - const action = planAction({ actionFor, actions, onAction }, plan); + const action = planAction({ actionFor, actions, onAction, pending, settled }, plan); return (
diff --git a/packages/blocks/src/keys/keys.tsx b/packages/blocks/src/keys/keys.tsx index 9940619e..728a9d79 100644 --- a/packages/blocks/src/keys/keys.tsx +++ b/packages/blocks/src/keys/keys.tsx @@ -7,27 +7,33 @@ import { TableRow, } from "@locusgraph/components"; import type * as React from "react"; -import { BlockActions } from "../actions/block-actions"; +import { BlockActionOutcome, BlockActions } from "../actions/block-actions"; import { LocalDate } from "../dates/local-date"; -import type { ApiKeyRow, BlockAction } from "../types"; +import type { ApiKeyRow, BlockAction, PendingBlockAction, SettledBlockAction } from "../types"; type KeysProps = { keys: ApiKeyRow[]; actions?: ((key: ApiKeyRow) => React.ReactNode) | BlockAction[]; onAction?: (actionId: string, key: ApiKeyRow) => void; highlightedId?: string; + pending?: PendingBlockAction; + settled?: SettledBlockAction; }; -function keyActions( - { actions, onAction }: Pick, - key: ApiKeyRow -) { +type KeyActionProps = Pick; + +function keyActions({ actions, onAction, pending, settled }: KeyActionProps, key: ApiKeyRow) { if (typeof actions === "function") return actions(key); + if (settled?.target === key.id) { + return ; + } return ( onAction(actionId, key))} /> ); @@ -51,7 +57,7 @@ function expiryPhrase(expiresAt: string | null) { ); } -export function Keys({ keys, actions, onAction, highlightedId }: KeysProps) { +export function Keys({ keys, actions, onAction, highlightedId, pending, settled }: KeysProps) { const showActions = hasActions(actions); return ( @@ -91,7 +97,7 @@ export function Keys({ keys, actions, onAction, highlightedId }: KeysProps) { {dateOr(key.expiresAt, "Never")} {showActions && ( - {keyActions({ actions, onAction }, key)} + {keyActions({ actions, onAction, pending, settled }, key)} )} diff --git a/packages/blocks/src/types.ts b/packages/blocks/src/types.ts index 62f70e61..1badc586 100644 --- a/packages/blocks/src/types.ts +++ b/packages/blocks/src/types.ts @@ -9,6 +9,10 @@ export type BlockAction = { disabled?: boolean; }; +export type PendingBlockAction = { actionId: string; target: string }; + +export type SettledBlockAction = { actionId: string; target: string; label: string }; + export type QuotaPeriod = "month" | "lifetime"; export type QuotaData = { diff --git a/packages/json-render/README.md b/packages/json-render/README.md index 0b05ceff..f2b95fb3 100644 --- a/packages/json-render/README.md +++ b/packages/json-render/README.md @@ -40,6 +40,53 @@ const systemPrompt = catalog.prompt(); | `Plans` | `plans`, `actions` | `action`, after writing `{ actionId, target }` to `/picked` | | `Keys` | `keys`, `actions` | `action`, after writing `{ actionId, target }` to `/picked` | -Props are nullable rather than optional, because models write `null` more reliably than they leave a key out. Add your own components next to these by spreading both objects into your catalog and registry. +Props are nullable rather than optional, because models write `null` more reliably than they leave a key out. + +## Extend the catalog + +Spread both objects, then add your own entry under the same name in the catalog and the registry. `catalog.prompt()` lists it for the model. + +```tsx +const catalog = defineCatalog(schema, { + components: { + ...locusComponents, + StatusBadge: { + props: z.object({ label: z.string(), tone: z.enum(["success", "warning"]).nullable() }), + description: "A short status word next to a block", + }, + }, + actions: {}, +}); + +const { registry } = defineRegistry(catalog, { + components: { + ...locusRenderers, + StatusBadge: ({ props }) => {props.label}, + }, +}); +``` + +Replace one by reusing its key after the spread (`{ ...locusRenderers, Wallet: MyWallet }`), and remove one by destructuring it out of both objects. More at [design.locusgraph.com/generative-ui](https://design.locusgraph.com/generative-ui), in the Extend tab. Put a person in front of a risky action with `useApprovalGate` from `@locusgraph/loop`: `await gate.ask({ toolName, risk, input })` in the action handler, before anything runs. + +## One click, one run + +Wrap each handler in `guardAction`. A second click while the first runs does nothing, and the blocks show the state: + +```tsx +pickedAction: guardAction(async (picked) => { + const decision = await gate.ask({ toolName: picked.actionId, risk: "write", input: picked }); + if (!decision.approved) return null; + await run(picked); + return `Moved to ${picked.target}`; +}), +``` + +| State | Written by | What the blocks do | +|---|---|---| +| `/pending` | `guardAction`, while the run is in flight | Every action is disabled; the clicked one shows a spinner | +| `/settled` | `guardAction`, with the label you return | The label replaces the buttons on the target; the rest stay disabled | +| `/locked` | You, on an older turn | Every action is disabled | + +Return `null` (a denial, say) to leave the turn open. Give each turn in a chat its own state store, and set `/locked` on the older ones when a new turn arrives. diff --git a/packages/json-render/package.json b/packages/json-render/package.json index aced3d46..f45b3349 100644 --- a/packages/json-render/package.json +++ b/packages/json-render/package.json @@ -1,6 +1,6 @@ { "name": "@locusgraph/json-render", - "version": "0.1.1", + "version": "0.2.0", "description": "A json-render catalog and registry for LocusGraph components and blocks, so a model can lay out LocusGraph screens as JSON.", "license": "MIT", "repository": { diff --git a/packages/json-render/src/actions/guard-action.ts b/packages/json-render/src/actions/guard-action.ts new file mode 100644 index 00000000..fd06f4a2 --- /dev/null +++ b/packages/json-render/src/actions/guard-action.ts @@ -0,0 +1,34 @@ +import type { SetState } from "@json-render/react"; + +export type PickedAction = { actionId: string; target: string }; + +export type SettledAction = PickedAction & { label: string }; + +export type GuardedRun = ( + picked: PickedAction, + state: Record +) => Promise; + +function isLocked(state: Record) { + return Boolean(state.pending || state.settled || state.locked); +} + +export function guardAction(run: GuardedRun) { + let running = false; + return async (_params: unknown, setState: SetState, state: Record) => { + const picked = state.picked as PickedAction | undefined; + if (!picked || running || isLocked(state)) return; + running = true; + setState((previous) => ({ ...previous, pending: picked })); + try { + const label = await run(picked, state); + const settled: SettledAction | null = label ? { ...picked, label } : null; + setState((previous) => ({ ...previous, pending: null, settled })); + } catch (error) { + setState((previous) => ({ ...previous, pending: null })); + throw error; + } finally { + running = false; + } + }; +} diff --git a/packages/json-render/src/index.ts b/packages/json-render/src/index.ts index ddad543c..88d51e69 100644 --- a/packages/json-render/src/index.ts +++ b/packages/json-render/src/index.ts @@ -1,4 +1,15 @@ +export { + type GuardedRun, + guardAction, + type PickedAction, + type SettledAction, +} from "./actions/guard-action"; export { type LocusComponentName, locusComponents } from "./catalog/index"; -export { blockActions, quotasWithoutNullLabels } from "./registry/conversions"; +export { + blockActions, + keyTarget, + quotasWithoutNullLabels, + withKeyId, +} from "./registry/conversions"; export { locusRenderers } from "./registry/index"; export type { LocusProps, LocusRendererProps } from "./registry/props"; diff --git a/packages/json-render/src/registry/blocks.tsx b/packages/json-render/src/registry/blocks.tsx index 6f7b2a58..3dfb300b 100644 --- a/packages/json-render/src/registry/blocks.tsx +++ b/packages/json-render/src/registry/blocks.tsx @@ -1,10 +1,18 @@ "use client"; -import { useStateStore } from "@json-render/react"; +import { useStateStore, useStateValue } from "@json-render/react"; import { Keys, Plans, TopUp, Usage, Wallet } from "@locusgraph/blocks"; -import { blockActions, quotasWithoutNullLabels } from "./conversions"; +import type { PickedAction, SettledAction } from "../actions/guard-action"; +import { blockActions, keyTarget, quotasWithoutNullLabels, withKeyId } from "./conversions"; import type { LocusRendererProps } from "./props"; +function useActionState() { + const pending = useStateValue("/pending") ?? undefined; + const settled = useStateValue("/settled") ?? undefined; + const locked = useStateValue("/locked") === true; + return { pending, settled, locked }; +} + export function LocusUsage({ props }: LocusRendererProps<"Usage">) { return ; } @@ -15,19 +23,31 @@ export function LocusWallet({ props }: LocusRendererProps<"Wallet">) { export function LocusTopUp({ props, emit }: LocusRendererProps<"TopUp">) { const { set } = useStateStore(); + const { pending, settled, locked } = useActionState(); const buy = (packId: string) => { set("/selectedPackId", packId); + set("/picked", { actionId: "buy", target: packId }); emit("buy"); }; - return ; + return ( + + ); } export function LocusPlans({ props, emit }: LocusRendererProps<"Plans">) { const { set } = useStateStore(); + const { pending, settled, locked } = useActionState(); return ( { set("/picked", { actionId, target: plan.name }); emit("action"); @@ -38,12 +58,15 @@ export function LocusPlans({ props, emit }: LocusRendererProps<"Plans">) { export function LocusKeys({ props, emit }: LocusRendererProps<"Keys">) { const { set } = useStateStore(); + const { pending, settled, locked } = useActionState(); return ( { - set("/picked", { actionId, target: key.name ?? key.maskedKey }); + set("/picked", { actionId, target: keyTarget(key) }); emit("action"); }} /> diff --git a/packages/json-render/src/registry/conversions.ts b/packages/json-render/src/registry/conversions.ts index 783af031..1daceb25 100644 --- a/packages/json-render/src/registry/conversions.ts +++ b/packages/json-render/src/registry/conversions.ts @@ -1,19 +1,31 @@ -import type { BlockAction, QuotaData } from "@locusgraph/blocks"; +import type { ApiKeyRow, BlockAction, QuotaData } from "@locusgraph/blocks"; import type { LocusProps } from "./props"; type SpecAction = LocusProps<"Plans">["actions"][number]; type SpecQuota = LocusProps<"Usage">["quotas"][number]; -export function blockActions(actions: SpecAction[]): BlockAction[] { +type SpecKey = LocusProps<"Keys">["keys"][number]; + +export function blockActions(actions: SpecAction[], locked = false): BlockAction[] { return actions.map(({ id, label, variant, disabled }) => ({ id, label, variant: variant ?? undefined, - disabled: disabled ?? undefined, + disabled: locked || (disabled ?? undefined), })); } +export function keyTarget(key: Pick) { + return key.name ?? key.maskedKey; +} + +export function withKeyId(action: A | undefined, keys: SpecKey[]) { + if (!action) return undefined; + const key = keys.find((candidate) => keyTarget(candidate) === action.target); + return key ? { ...action, target: key.id } : action; +} + export function quotasWithoutNullLabels(quotas: SpecQuota[]): QuotaData[] { return quotas.map(({ label, ...rest }) => (label === null ? rest : { ...rest, label })); } diff --git a/packages/json-render/src/tests/guard-action.test.tsx b/packages/json-render/src/tests/guard-action.test.tsx new file mode 100644 index 00000000..45e4dcfa --- /dev/null +++ b/packages/json-render/src/tests/guard-action.test.tsx @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; +import { guardAction } from "../actions/guard-action"; + +function harness(initial: Record) { + let state = initial; + const setState = (updater: (previous: Record) => Record) => { + state = updater(state); + }; + return { setState, read: () => state }; +} + +const picked = { actionId: "upgrade", target: "Team" }; + +describe("guardAction", () => { + it("runs once while a click is in flight, then settles with the label", async () => { + let finish: (label: string) => void = () => {}; + const run = vi.fn(() => new Promise((resolve) => (finish = resolve))); + const handler = guardAction(run); + const { setState, read } = harness({ picked }); + const first = handler({}, setState, read()); + await handler({}, setState, { picked }); + expect(run).toHaveBeenCalledOnce(); + expect(read().pending).toEqual(picked); + finish("Moved to Team"); + await first; + expect(read().pending).toBeNull(); + expect(read().settled).toEqual({ ...picked, label: "Moved to Team" }); + }); + + it("leaves the turn open when the run returns null", async () => { + const handler = guardAction(async () => null); + const { setState, read } = harness({ picked }); + await handler({}, setState, read()); + expect(read().pending).toBeNull(); + expect(read().settled).toBeNull(); + }); + + it("does nothing on a settled or locked turn", async () => { + const run = vi.fn(async () => "Done"); + const handler = guardAction(run); + const { setState } = harness({}); + await handler({}, setState, { picked, settled: { ...picked, label: "Done" } }); + await handler({}, setState, { picked, locked: true }); + expect(run).not.toHaveBeenCalled(); + }); + + it("clears pending when the run throws", async () => { + const handler = guardAction(async () => { + throw new Error("network"); + }); + const { setState, read } = harness({ picked }); + await expect(handler({}, setState, read())).rejects.toThrow("network"); + expect(read().pending).toBeNull(); + }); +});