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-pilot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"agent-bundle": patch
---

The Workbench Agent Document panel migrated its hand-rolled request state to
`@effect/atom-react` atoms keyed by run id under a root `RegistryProvider`,
keeping the strict zod decoding and imperative client lifecycles unchanged.
44 changes: 39 additions & 5 deletions docs/effect-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,37 @@ error channel, and crosses back to the public Promise API only through the
runtime boundary. It adopts no unstable Effect modules and starts no fibers,
timers, or workers between invocations.

## Workbench browser state (#105 phase 1)

The Workbench (`packages/workbench`) is an internal React client, never a
published API surface; its dist ships inside the `agent-bundle` package as
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`).
Atoms live in `effect/unstable/reactivity`; React bindings come from
`@effect/atom-react`.

- No `Atom` or `AsyncResult` types in DTOs, public exports, or examples —
atoms consume the strictly-decoded outputs of the existing zod clients and
never replace wire contracts.
- One root `RegistryProvider` mounted in the app shell (`src/main.tsx`); the
module-level default registry is never used.
- Components never call `Effect.run*` — the registry owns effect execution;
components interact through the `@effect/atom-react` hooks only (rslint
`effect-boundary/no-ad-hoc-run` already enforces the run ban).
- Imperative clients (`ProjectClient`, `RuntimeClient`, `AgentDocumentClient`,
…) stay the lifecycle authorities; atoms are read-side caches over their
decoded outputs.
- Known caveat: `4.0.0-rc.112` has a stream-backed derived-atom disposal bug
(fixed upstream post-rc.112, unpublished) — no stream-backed derived atoms
in the Workbench until a re-pin past the fix; the root `RegistryProvider`
(not the default registry) avoids the reported React case.
- Every effect re-pin must bump `@effect/atom-react` to the same RC in the
same chore, re-run the Workbench disposal regression test, and re-measure
the Workbench production bundle (the rsbuild build emits the size table).

## Banned modules and APIs

- `Effect.runPromise` / `runSync` / `runFork` / `runCallback` (and `*With` /
Expand All @@ -196,7 +227,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 |

## Language service

Expand Down Expand Up @@ -224,7 +255,10 @@ must not regress it: `pnpm bench:hook-cold-start -- --check`.
## Re-pin chore

1. Bump the exact `effect` version in `packages/rsc-runtime/package.json`.
2. `git subtree pull --prefix=repos/effect https://github.com/Effect-TS/effect.git main --squash`.
3. Re-read `repos/effect/LLMS.md` and refresh `agent-patterns/effect-*.md`.
4. Re-verify every unstable-module row and the language-service diagnostics.
5. Re-run the hook cold-start check.
2. Synchronize `@effect/atom-react` in `packages/workbench/package.json` to
the same RC; re-run the Workbench disposal regression test and production
bundle measurement (rsbuild size table).
3. `git subtree pull --prefix=repos/effect https://github.com/Effect-TS/effect.git main --squash`.
4. Re-read `repos/effect/LLMS.md` and refresh `agent-patterns/effect-*.md`.
5. Re-verify every unstable-module row and the language-service diagnostics.
6. Re-run the hook cold-start check.
3 changes: 3 additions & 0 deletions packages/workbench/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@
"typecheck": "tsc --project tsconfig.json"
},
"dependencies": {
"@effect/atom-react": "4.0.0-rc.112",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/ext-apps": "1.7.5",
"@modelcontextprotocol/sdk": "1.30.0",
"effect": "4.0.0-rc.112",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-markdown": "10.1.0",
"remark-gfm": "4.0.1",
"scheduler": "0.27.0",
"shiki": "4.4.3",
"zod": "4.4.3"
},
Expand Down
3 changes: 2 additions & 1 deletion packages/workbench/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { RegistryProvider } from '@effect/atom-react';
import { type KeyboardEvent as ReactKeyboardEvent, type MutableRefObject, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import type { Diagnostic } from '../../agent-bundle/src/contracts/diagnostics.ts';
Expand Down Expand Up @@ -1445,4 +1446,4 @@ const Workbench = () => {
return <main className="loading-state" aria-live="polite"><strong>Loading project state…</strong>{runtimeError === undefined ? undefined : <p className="runtime-capability-error">Runtime capability issue: {runtimeError}</p>}</main>;
};

createRoot(document.getElementById('root')!).render(<Workbench />);
createRoot(document.getElementById('root')!).render(<RegistryProvider><Workbench /></RegistryProvider>);
51 changes: 16 additions & 35 deletions packages/workbench/src/runtime-inspector.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import React, { useEffect, useRef, useState, type KeyboardEvent } from 'react';
import { useAtomValue } from '@effect/atom-react';
import { AsyncResult } from 'effect/unstable/reactivity';
import React, { useRef, useState, type KeyboardEvent } from 'react';

import type { DevRuntimeDiagnostic, DevRuntimeRun, DevRuntimeStatus, DevRuntimeSurface, DevRuntimeTreeNode } from '../../agent-bundle/src/contracts/runtime.ts';
import { RuntimeEvidence } from './runtime-evidence.tsx';
import type { RuntimeInspectorTab } from './runtime-model.ts';
import { agentDocumentEventsAtom, useAgentDocumentLoader } from './runtime/agent-document-atoms.ts';
import type { AgentRenderEvent } from './runtime/agent-document-client.ts';
import { AgentDocumentStage } from './runtime/agent-document-stage.tsx';

Expand Down Expand Up @@ -55,48 +58,26 @@ const resultDiagnostics = (run: DevRuntimeRun | undefined, status: DevRuntimeSta
...(run?.status === 'failed' ? run.diagnostics : []),
];

type RuntimeDocumentState =
| Readonly<{ readonly phase: 'idle' }>
| Readonly<{ readonly phase: 'loading'; readonly runId: string }>
| Readonly<{ readonly events: readonly AgentRenderEvent[]; readonly phase: 'ready'; readonly runId: string }>
| Readonly<{ readonly message: string; readonly phase: 'error'; readonly runId: string }>;
const RuntimeDocumentResult = ({ runId }: Readonly<{ readonly runId: string }>): React.ReactNode => {
const result = useAtomValue(agentDocumentEventsAtom(runId));
return AsyncResult.matchWithWaiting(result, {
onDefect: (error) => <p role="alert">{error instanceof Error ? error.message : 'Agent Document request could not be completed.'}</p>,
onError: (message) => <p role="alert">{message}</p>,
onSuccess: ({ value: events }) => <AgentDocumentStage events={events} />,
onWaiting: () => <p role="status">Loading Agent Document…</p>,
});
};

const RuntimeDocumentPanel = ({ loadDocumentEvents, run }: Pick<RuntimeInspectorProps, 'loadDocumentEvents' | 'run'>): React.ReactNode => {
const [state, setState] = useState<RuntimeDocumentState>({ phase: 'idle' });
const loaderReady = useAgentDocumentLoader(loadDocumentEvents);
const selected = run?.status === 'succeeded' ? run.result : undefined;
const canLoad = run?.status === 'succeeded' && selected?.flight !== undefined && loadDocumentEvents !== undefined;

useEffect(() => {
if (!canLoad || run === undefined || loadDocumentEvents === undefined) {
setState({ phase: 'idle' });
return;
}
const controller = new AbortController();
setState({ phase: 'loading', runId: run.id });
void loadDocumentEvents(run.id, controller.signal).then(
(events) => {
if (!controller.signal.aborted) setState({ events, phase: 'ready', runId: run.id });
},
(error: unknown) => {
if (!controller.signal.aborted) {
setState({
message: error instanceof Error ? error.message : 'Agent Document request could not be completed.',
phase: 'error',
runId: run.id,
});
}
},
);
return () => controller.abort();
}, [canLoad, loadDocumentEvents, run]);

if (run === undefined) return <p>Select a runtime run to inspect its Agent Document.</p>;
if (run.status !== 'succeeded') return <p role="alert">This run did not succeed, so it has no decodable Agent Document.</p>;
if (selected?.flight === undefined) return <p role="alert">This run has no stored Flight payload to decode as an Agent Document.</p>;
if (loadDocumentEvents === undefined) return <p role="alert">Agent Document loading is not available in this Workbench session.</p>;
if (state.phase === 'idle' || state.runId !== run.id || state.phase === 'loading') return <p role="status">Loading Agent Document…</p>;
if (state.phase === 'error') return <p role="alert">{state.message}</p>;
return <AgentDocumentStage events={state.events} />;
if (!loaderReady) return <p role="status">Loading Agent Document…</p>;
return <RuntimeDocumentResult runId={run.id} />;
};

export const RuntimeInspector = ({ loadDocumentEvents, onDownloadFlight, onTabChange, run, status, surface, tab, traceExpansion }: RuntimeInspectorProps): React.ReactNode => {
Expand Down
8 changes: 6 additions & 2 deletions packages/workbench/src/runtime-playground.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';

import type {
DevRuntimeInvocationRequest,
Expand Down Expand Up @@ -440,6 +440,10 @@ const resetSeedLabel = (request: DevRuntimeStateResetRequest): string =>

export const RuntimePlayground = ({ controller, liveMcpPageAdapter = runtimePlaygroundLiveMcpPageAdapter, registerAppPreviewLifecycle, renderAppPreview }: RuntimePlaygroundProps): React.ReactNode => {
const [model, setModel] = useState(controller.model);
const loadDocumentEvents = useCallback(
(runId: string, signal?: AbortSignal) => controller.readRunDocument(runId, signal),
[controller],
);
const cancelRef = useRef<HTMLButtonElement>(null);
const confirmRef = useRef<HTMLButtonElement>(null);
const invokingRef = useRef<HTMLButtonElement>(null);
Expand Down Expand Up @@ -655,7 +659,7 @@ export const RuntimePlayground = ({ controller, liveMcpPageAdapter = runtimePlay
/>
{flightDownloadError === undefined ? undefined : <p className="runtime-request-error" role="alert">{flightDownloadError}</p>}
<RuntimeInspector
loadDocumentEvents={(runId, signal) => controller.readRunDocument(runId, signal)}
loadDocumentEvents={loadDocumentEvents}
onDownloadFlight={downloadFlight}
onTabChange={(tab) => controller.dispatch({ tab, type: 'selection.tab' })}
run={run}
Expand Down
32 changes: 32 additions & 0 deletions packages/workbench/src/runtime/agent-document-atoms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useAtom } from '@effect/atom-react';
import { Effect } from 'effect';
import { Atom } from 'effect/unstable/reactivity';
import { useEffect } from 'react';

import type { AgentRenderEvent } from './agent-document-client.ts';

type AgentDocumentLoader = (runId: string, signal?: AbortSignal) => Promise<readonly AgentRenderEvent[]>;

export const agentDocumentLoaderAtom = Atom.make<AgentDocumentLoader | undefined>(undefined);

export const agentDocumentEventsAtom = Atom.family((runId: string) => Atom.make((get) => {
const loader = get.once(agentDocumentLoaderAtom);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invalidate document atoms when the loader changes

When a mounted RuntimeInspector receives a replacement loadDocumentEvents callback while displaying the same run, useAgentDocumentLoader updates the loader atom, but get.once establishes no dependency and the family is keyed only by runId. Because loaderReady also remains true whenever any previous loader exists, the result atom stays mounted and continues using the old client/request or cached result indefinitely; this regresses the previous effect, which reloaded whenever loadDocumentEvents changed. Include the loader identity in the atom lifecycle or unmount/refresh the result atom during loader replacement.

Useful? React with 👍 / 👎.

if (loader === undefined) {
return Effect.fail('Agent Document loading is not available in this Workbench session.');
}
return Effect.tryPromise({
catch: (error) => error instanceof Error ? error.message : 'Agent Document request could not be completed.',
try: (signal) => loader(runId, signal),
});
}));

export const useAgentDocumentLoader = (loader: AgentDocumentLoader | undefined): boolean => {
const [current, setCurrent] = useAtom(agentDocumentLoaderAtom);
useEffect(() => {
setCurrent((latest: AgentDocumentLoader | undefined) => latest === loader ? latest : loader);
return () => {
setCurrent((latest: AgentDocumentLoader | undefined) => latest === loader ? undefined : latest);
};
}, [loader, setCurrent]);
return loader === undefined || current !== undefined;
};
Loading
Loading