Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/design/src/demos/blocks/billing.demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
7 changes: 6 additions & 1 deletion apps/design/src/demos/blocks/generative-ui.demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default defineDemo({
controls: { spec: select(presetNames, "over-quota") },
playground: ({ spec }) => <SpecPlayground key={spec} initialSpec={presets[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",
Expand All @@ -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: {
Expand Down
51 changes: 20 additions & 31 deletions apps/design/src/demos/blocks/generative-ui/catalog.ts
Original file line number Diff line number Diff line change
@@ -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<ApprovalDecision>;

type Picked = { actionId: string; target: string };

export const catalog = defineCatalog(schema, {
components: { ...locusComponents },
actions: {
Expand All @@ -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, (target: string) => 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 },
});
}
13 changes: 6 additions & 7 deletions apps/design/src/demos/blocks/generative-ui/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -56,7 +57,6 @@ export const overQuotaSpec: Spec = {
},
on: { action: { action: "pickedAction" } },
},
done: lastAction,
},
};

Expand Down Expand Up @@ -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: {
Expand All @@ -96,7 +96,6 @@ export const keysSpec: Spec = {
},
on: { action: { action: "pickedAction" } },
},
done: lastAction,
},
};

Expand Down
10 changes: 10 additions & 0 deletions apps/design/src/demos/blocks/keys.demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
],
Expand Down
13 changes: 13 additions & 0 deletions apps/design/src/shell/guides/changelog/changelog-entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
123 changes: 123 additions & 0 deletions apps/design/src/shell/guides/generative-ui/docs/extend-guide.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<Badge variant={props.tone ?? "secondary"}>{props.label}</Badge>
),
},
});`;

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 (
<section className="flex flex-col gap-2">
<h3 className="font-heading text-sm font-semibold text-foreground">{title}</h3>
{children}
</section>
);
}

function Note({ children }: { children: React.ReactNode }) {
return <p className="text-sm text-pretty text-muted-foreground">{children}</p>;
}

function Snippet({ code }: { code: string }) {
return (
<CodeBlock
code={code}
collapsible={false}
className="border bg-background [&_.shiki]:text-xs"
/>
);
}

export function ExtendGuide() {
return (
<>
<Note>
Spread <code>locusComponents</code> into your catalog and <code>locusRenderers</code> into
your registry, then add your own entries under the same name in both.
</Note>
<Step title="Add a component">
<Snippet code={addComponentCode} />
<Note>
<code>catalog.prompt()</code> now lists it for the model. Build renderers from{" "}
<code>@locusgraph/components</code> so they keep the Locus Design look.
</Note>
</Step>
<Step title="Replace one">
<Snippet code={replaceCode} />
<Note>The later key wins. Keep the props, or replace the catalog entry too.</Note>
</Step>
<Step title="Remove one">
<Snippet code={removeCode} />
</Step>
<Step title="Add an action">
<Snippet code={actionCode} />
<Note>
<code>guardAction</code> runs one click at a time. While it runs, <code>/pending</code>{" "}
locks every button and spins the one clicked. The label you return goes to{" "}
<code>/settled</code>: the block shows it in place of the buttons and the turn stays
frozen. Return <code>null</code>, as on a denial, to leave the turn open.
</Note>
</Step>
<Step title="Freeze older turns">
<Snippet code={lockTurnCode} />
<Note>
Give each turn its own state store. When the next turn arrives, lock the older one so its
buttons cannot fire again.
</Note>
</Step>
<Step title="Keep the model reliable">
<ul className="flex list-disc flex-col gap-1 pl-4 text-sm text-pretty text-muted-foreground">
<li>Nullable props, not optional: models write null more reliably.</li>
<li>Bind numbers to state with $state; the model picks the layout only.</li>
<li>Each description says when to use the component. The model reads it.</li>
</ul>
</Step>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -20,6 +21,7 @@ export function GenerativeDocsPanel({ editor, prompt }: GenerativeDocsPanelProps
<TabsTrigger value="spec">Spec</TabsTrigger>
<TabsTrigger value="catalog">Catalog</TabsTrigger>
<TabsTrigger value="prompt">Prompt</TabsTrigger>
<TabsTrigger value="extend">Extend</TabsTrigger>
</TabsList>
</div>
<TabsContent value="spec" className="flex min-h-0 flex-1 flex-col">
Expand All @@ -34,6 +36,9 @@ export function GenerativeDocsPanel({ editor, prompt }: GenerativeDocsPanelProps
note="catalog.prompt() writes this from the catalog. Send it as the system prompt."
/>
</TabsContent>
<TabsContent value="extend" className="flex min-h-0 flex-col gap-5 overflow-y-auto p-4">
<ExtendGuide />
</TabsContent>
</Tabs>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof useGenerativeSession>;
4 changes: 2 additions & 2 deletions apps/example-react/src/json-render/billing-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
},
},
};
3 changes: 2 additions & 1 deletion apps/example-react/src/json-render/json-render-demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ function createBillingStore() {
keys,
selectedPackId: null,
picked: null,
lastAction: null,
pending: null,
settled: null,
});
}

Expand Down
Loading
Loading