Repository navigation
Description
Description
In the web UI, an extension editor dialog that opens after an input dialog shows the text typed into the input, not its own prefill. If the user submits without looking, the extension gets the old text back.
This is not only about the next dialog. Every later input or editor dialog in the same chat view can start with the last typed text. A page reload clears it.
The cause is how the React Compiler compiles the per-request draft (line numbers are from main at 52ae5478).
ChatTranscript builds the PendingExtUi object in a useMemo keyed by the request. The memo callback has a local let draft and the draft() / rememberDraft() closures (apps/web/src/chat/ChatTranscript.tsx:558-581). The chat spec says the draft lives in that per-request object, and "a reply or dismissal ends the request and its draft with it" (apps/web/src/chat/SPEC.md:527-530).
The web app is built with the React Compiler in every build mode (apps/web/vite.config.ts:28, babel-plugin-react-compiler 1.0.0). In the compiled ChatTranscript, request and reply still change with the request. But the draft, rememberDraft and claimReveal closures are cached in slots that are filled only once, when the first request comes. Every later request gets the same closures, so they all read and write the draft variable of the first request. From the dev build, shortened:
if ($[127] === Symbol.for("react.memo_cache_sentinel")) {
t44 = () => draft;
t45 = (text) => { draft = text; };
t46 = () => { /* claimReveal */ };
$[127] = t44; $[128] = t45; $[129] = t46;
} else {
t44 = $[127]; t45 = $[128]; t46 = $[129];
}ExtUiPrompt then starts from draft ?? prefill (apps/web/src/chat/ExtUiPrompt.tsx:35-37), so the shared draft wins over the new prefill.
To check this I added "use no memo" to ChatTranscript locally. With the same steps, the editor showed its prefill.
claimReveal is cached in the same block, so its revealed flag is shared too. From reading the compiled code, I think only the first tool-anchored dialog in a chat view scrolls itself into view. I didn't test this in the app.
An idea for the fix: keep the draft somewhere that doesn't depend on how the compiler caches closures, for example a ref keyed by the request id.
Steps to reproduce
-
Add a global extension, for example
~/.pi/agent/extensions/dialogs.ts:export default function (pi) { pi.registerCommand("dialogs", { description: "input dialog, then editor dialog with a prefill", handler: async (_args, ctx) => { const typed = await ctx.ui.input("Step 1: type a name", "a name"); const edited = await ctx.ui.editor("Step 2: edit the template", "PREFILL FROM EXTENSION"); ctx.ui.notify(`input returned ${JSON.stringify(typed)}; editor returned ${JSON.stringify(edited)}`, "info"); }, }); }
-
Start ThinkRail with
bun run dev, open a project and start a chat. No provider is needed: an extension command doesn't call the model. -
Send
/dialogs. Typetyped-into-inputinto the input dialog and press Submit. -
Look at the editor dialog, then press Submit without changes.
Expected behavior
The editor dialog shows PREFILL FROM EXTENSION. The notice says editor returned "PREFILL FROM EXTENSION".
Actual behavior
The editor dialog shows typed-into-input. The notice says input returned "typed-into-input"; editor returned "typed-into-input".
In a second run I typed first-run into the input and pressed Submit, then pressed Cancel in the editor and sent /dialogs again. The new input dialog already had first-run in it. After a page reload the first dialog is empty again.
Environment
- OS: macOS 27.0.1
- Bun version (
bun --version): 1.4.4 - Node version (
node --version): v24.10.0 - ThinkRail version / commit: main
52ae5478, pi 1.5.2, web UI frombun run devin a Chromium-based browser
Activity