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();
+ });
+});