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
7 changes: 7 additions & 0 deletions .changeset/workbench-atom-phase2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"agent-bundle": patch
---

The Workbench route input editor migrated its coordinated local state to
`@effect/atom-react` atoms keyed by manifest digest and compiled route id,
preserving typed and raw validation behavior while releasing state on unmount.
5 changes: 3 additions & 2 deletions docs/effect-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,8 @@ static assets.

Exact-pin `effect` + `@effect/atom-react` (synchronized with the repo's effect
pin, currently `4.0.0-rc.112`) are allowed there, but only in dedicated
browser-state modules (the first is `src/runtime/agent-document-atoms.ts`).
browser-state modules (`src/runtime/agent-document-atoms.ts` and
`src/routes/route-editor-atoms.ts`).
Atoms live in `effect/unstable/reactivity`; React bindings come from
`@effect/atom-react`.

Expand Down Expand Up @@ -227,7 +228,7 @@ The #99 notice ledger also adopts none: it needs only stable `Effect` and

| Module | Adopted in | Re-verify |
| --- | --- | --- |
| `effect/unstable/reactivity` (+ `@effect/atom-react` bindings) | Workbench Agent Document panel (#105 phase 1) | re-pin bumps @effect/atom-react in lockstep; re-run disposal regression + bundle measurement; stream-backed derived atoms stay banned until the rc.112 disposal fix ships |
| `effect/unstable/reactivity` (+ `@effect/atom-react` bindings) | Workbench Agent Document panel (#105 phase 1) and route editor (#105 phase 2) | re-pin bumps @effect/atom-react in lockstep; re-run disposal regression + bundle measurement; stream-backed derived atoms stay banned until the rc.112 disposal fix ships |

## Language service

Expand Down
13 changes: 13 additions & 0 deletions packages/workbench/src/routes/route-editor-atoms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Atom } from 'effect/unstable/reactivity';

import type { RouteEditorState } from './routes-model.ts';

/**
* Digest plus compiled route id isolates editor state across manifests. The
* undefined sentinel lets provider-less SSR derive schema defaults locally.
*/
export const routeEditorKey = (digest: string, routeId: string): string => `${digest}\u0000${routeId}`;

export const routeEditorStateAtom = Atom.family(
(key: string) => Atom.make<RouteEditorState | undefined>(undefined),
);
88 changes: 88 additions & 0 deletions packages/workbench/src/routes/routes-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,16 @@ export interface RawRouteInputValidation {
readonly error?: string;
}

export interface RouteEditorState {
readonly attempted: boolean;
readonly arguments?: RouteInputArguments;
readonly argv?: string;
readonly draft: RouteInputDraft;
readonly errors: Readonly<Record<string, string>>;
readonly raw: string;
readonly rawError?: string;
}

export interface McpToolPrefill {
readonly arguments: RouteInputArguments;
readonly serverName: string;
Expand Down Expand Up @@ -243,6 +253,13 @@ export const createRouteInputDraft = (schema: RouteInputSchema): RouteInputDraft
));
};

export const initialRouteEditorState = (schema?: RouteInputSchema): RouteEditorState => Object.freeze({
attempted: false,
draft: schema === undefined ? Object.freeze({}) : createRouteInputDraft(schema),
errors: Object.freeze({}),
raw: '{}',
});

export const routeInputLabel = (key: string): string => {
const words = key
.replace(/([a-z0-9])([A-Z])/gu, '$1 $2')
Expand Down Expand Up @@ -411,6 +428,77 @@ export const cliCommandInvocation = (
return argv.join(' ');
};

const routeEditorArgv = (
command: RouteManifestCliCommand | undefined,
argumentsValue: RouteInputArguments | undefined,
): string | undefined => argumentsValue === undefined || command === undefined
? undefined
: cliCommandInvocation(command, argumentsValue);

export const setRouteEditorDraftValue = (
state: RouteEditorState,
schema: RouteInputSchema | undefined,
command: RouteManifestCliCommand | undefined,
key: string,
value: RouteInputDraftValue | undefined,
): RouteEditorState => {
const entries = { ...state.draft };
if (value === undefined) {
delete entries[key];
} else {
entries[key] = value;
}
const draft = Object.freeze(entries);
if (!state.attempted || schema === undefined) return Object.freeze({ ...state, draft });
const validated = validateRouteInput(schema, draft);
return Object.freeze({
...state,
arguments: validated.arguments,
argv: routeEditorArgv(command, validated.arguments),
draft,
errors: Object.freeze({ ...validated.errors }),
});
};

export const setRouteEditorRaw = (
state: RouteEditorState,
raw: string,
): RouteEditorState => {
if (!state.attempted) return Object.freeze({ ...state, raw });
const validated = validateRawRouteInput(raw);
return Object.freeze({
...state,
arguments: validated.arguments,
raw,
rawError: validated.error,
});
};

export const validateRouteEditor = (
state: RouteEditorState,
schema: RouteInputSchema | undefined,
command: RouteManifestCliCommand | undefined,
): RouteEditorState => {
if (schema === undefined) {
const validated = validateRawRouteInput(state.raw);
return Object.freeze({
...state,
arguments: validated.arguments,
argv: routeEditorArgv(command, validated.arguments),
attempted: true,
rawError: validated.error,
});
}
const validated = validateRouteInput(schema, state.draft);
return Object.freeze({
...state,
arguments: validated.arguments,
argv: routeEditorArgv(command, validated.arguments),
attempted: true,
errors: Object.freeze({ ...validated.errors }),
});
};

export const mcpToolPrefillFor = (
group: RouteCatalogGroup,
entry: RouteCatalogEntry,
Expand Down
97 changes: 39 additions & 58 deletions packages/workbench/src/routes/routes-page.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import React, { useState } from 'react';
import { useAtom } from '@effect/atom-react';
import React from 'react';

import type { RouteInputPropertySchema } from '../../../agent-bundle/src/contracts/routes.ts';
import { routeEditorKey, routeEditorStateAtom } from './route-editor-atoms.ts';
import {
cliCommandInvocation,
cliCommandUsage,
createRouteInputDraft,
initialRouteEditorState,
mcpToolPrefillFor,
routeInputLabel,
validateRawRouteInput,
validateRouteInput,
setRouteEditorDraftValue,
setRouteEditorRaw,
validateRouteEditor,
type McpToolPrefill,
type RouteCatalog,
type RouteCatalogEntry,
type RouteCatalogGroup,
type RouteCatalogServer,
type RouteInputArguments,
type RouteInputDraft,
type RouteInputDraftValue,
} from './routes-model.ts';
import './routes-page.css';
Expand Down Expand Up @@ -113,57 +113,38 @@ const scalarControl = (
}
};

const RouteInputEditor = ({ entry, group, onOpenMcp }: {
const RouteInputEditor = ({ digest, entry, group, onOpenMcp }: {
readonly digest: string;
readonly entry: RouteCatalogEntry;
readonly group: RouteCatalogGroup;
readonly onOpenMcp?: (prefill: McpToolPrefill) => void;
}) => {
const schema = entry.inputSchema;
const [draft, setDraft] = useState<RouteInputDraft>(() => schema === undefined ? Object.freeze({}) : createRouteInputDraft(schema));
const [raw, setRaw] = useState('{}');
const [errors, setErrors] = useState<Readonly<Record<string, string>>>({});
const [rawError, setRawError] = useState<string>();
const [attempted, setAttempted] = useState(false);
const [argumentsValue, setArgumentsValue] = useState<RouteInputArguments>();
const [argv, setArgv] = useState<string>();

const commitValidation = (next: RouteInputDraft): void => {
if (schema === undefined || !attempted) return;
const validated = validateRouteInput(schema, next);
setErrors(validated.errors);
setArgumentsValue(validated.arguments);
setArgv(validated.arguments === undefined || entry.command === undefined
? undefined
: cliCommandInvocation(entry.command, validated.arguments));
};
const [stored, setStored] = useAtom(routeEditorStateAtom(routeEditorKey(digest, entry.id)));
const state = stored ?? initialRouteEditorState(schema);
const {
arguments: argumentsValue,
argv,
draft,
errors,
raw,
rawError,
} = state;
const setValue = (key: string, value: RouteInputDraftValue | undefined): void => {
const entries = { ...draft };
if (value === undefined) {
delete entries[key];
} else {
entries[key] = value;
}
const next = Object.freeze(entries);
setDraft(next);
commitValidation(next);
setStored((current) => setRouteEditorDraftValue(
current ?? initialRouteEditorState(schema),
schema,
entry.command,
key,
value,
));
};
const validate = (): void => {
setAttempted(true);
if (schema === undefined) {
const validated = validateRawRouteInput(raw);
setRawError(validated.error);
setArgumentsValue(validated.arguments);
setArgv(validated.arguments === undefined || entry.command === undefined
? undefined
: cliCommandInvocation(entry.command, validated.arguments));
return;
}
const validated = validateRouteInput(schema, draft);
setErrors(validated.errors);
setArgumentsValue(validated.arguments);
setArgv(validated.arguments === undefined || entry.command === undefined
? undefined
: cliCommandInvocation(entry.command, validated.arguments));
setStored((current) => validateRouteEditor(
current ?? initialRouteEditorState(schema),
schema,
entry.command,
));
};
const openMcp = (): void => {
if (argumentsValue === undefined || onOpenMcp === undefined) return;
Expand All @@ -180,12 +161,10 @@ const RouteInputEditor = ({ entry, group, onOpenMcp }: {
id={editorId(entry.id, 'raw')}
onChange={(event) => {
const next = event.currentTarget.value;
setRaw(next);
if (attempted) {
const validated = validateRawRouteInput(next);
setRawError(validated.error);
setArgumentsValue(validated.arguments);
}
setStored((current) => setRouteEditorRaw(
current ?? initialRouteEditorState(schema),
next,
));
}}
rows={4}
value={raw}
Expand Down Expand Up @@ -251,7 +230,8 @@ const RouteInputEditor = ({ entry, group, onOpenMcp }: {
const commandSummary = (entry: RouteCatalogEntry): string | undefined =>
entry.command === undefined ? undefined : cliCommandUsage(entry.command);

const RouteGroup = ({ group, onOpenMcp }: {
const RouteGroup = ({ digest, group, onOpenMcp }: {
readonly digest: string;
readonly group: RouteCatalogGroup;
readonly onOpenMcp?: (prefill: McpToolPrefill) => void;
}) => <section
Expand All @@ -274,7 +254,7 @@ const RouteGroup = ({ group, onOpenMcp }: {
<td className="route-source">{entry.source}<span className="route-provenance">{entry.provenance}</span></td>
<td className="route-config">
<p className="route-config-summary">{configSummary(entry)}</p>
<RouteInputEditor entry={entry} group={group} onOpenMcp={onOpenMcp} />
<RouteInputEditor digest={digest} entry={entry} group={group} onOpenMcp={onOpenMcp} />
</td>
</tr>)}</tbody>
</table>
Expand Down Expand Up @@ -307,6 +287,7 @@ export const RoutesPage = ({ catalog, onOpenMcp }: RoutesPageProps) => <div clas
{catalog.servers.filter((server) => server.routeCount === 0)
.map((server) => <EmptyServerSurface key={server.id} server={server} />)}
{catalog.groups.map((group) => <RouteGroup
digest={catalog.digest}
group={group}
key={`${catalog.digest}-${group.serverId ?? 'project'}-${group.kind}`}
onOpenMcp={onOpenMcp}
Expand Down
Loading
Loading