Skip to content

Commit 622270d

Browse files
cliffhallclaude
andcommitted
Address review feedback: canSubmit, fire-time context, re-click guard
- PromptArgumentsForm: add a canSubmit guard that disables Get Prompt until every required argument has a value, matching the resource template form's symmetry. Optional args may stay blank. - Both forms: capture sibling context inside the debounce callback, not at schedule time. Hold the latest argumentValues / variables in a ref + sync via useEffect, and call buildContext() at fire time. Without this, typing in arg A then arg B within the 300ms window would ship A's request with B's pre-keystroke value. - PromptsScreen.handleSelectPrompt: early-return when the user re-clicks the already-selected prompt — sidebar is for navigation, ✕ is for dismiss. Previously a re-click wiped form values and re-fired the auto-fetch for no-arg prompts. - MessageBubble: expand the comment on effectiveMimeForBlock to flag the unconditional markdown promotion as a known asymmetry with the resource side (which only promotes when the server signals it), pending a per-block mimeType in the spec. - New tests: canSubmit disabled / enabled transitions, fire-time context capture across siblings, abort-path verification (a stale in-flight response must not overwrite the fresh one), and the no-op re-click guard on PromptsScreen. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 719e7be commit 622270d

6 files changed

Lines changed: 207 additions & 35 deletions

File tree

clients/web/src/components/elements/MessageBubble/MessageBubble.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@ function isRenderableBlock(block: unknown): block is ContentBlock {
3939
// markdown by default so prompt prose with code fences, lists, and links
4040
// looks like prose rather than a preformatted dump. Image / audio blocks
4141
// already carry mimeType; ContentViewer routes them itself.
42+
//
43+
// Caveat: this is unconditional — a server that emits a raw shell
44+
// snippet, log line, or string containing `#` / `_` / backticks will
45+
// have it transformed. Most prompts are prose so the trade-off is
46+
// worth it, but this differs from the resource side (where
47+
// ResourcePreviewPanel only promotes to markdown when the server
48+
// supplies `text/markdown` or the URI suffix matches). If the MCP
49+
// spec ever adds a per-block mimeType for prompt messages, switch
50+
// back to opt-in rendering here.
4251
function effectiveMimeForBlock(block: ContentBlock): string | undefined {
4352
if (block.type === "text") return "text/markdown";
4453
return undefined;

clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.test.tsx

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,8 @@ describe("PromptArgumentsForm", () => {
150150
renderWithMantine(
151151
<PromptArgumentsForm
152152
prompt={promptWithArgs}
153-
argumentValues={{}}
153+
// Required arg filled — otherwise the button is disabled.
154+
argumentValues={{ text: "Hello" }}
154155
onArgumentChange={vi.fn()}
155156
onGetPrompt={onGetPrompt}
156157
/>,
@@ -159,6 +160,33 @@ describe("PromptArgumentsForm", () => {
159160
expect(onGetPrompt).toHaveBeenCalledTimes(1);
160161
});
161162

163+
it("disables Get Prompt until every required argument has a value", async () => {
164+
const user = userEvent.setup();
165+
renderWithMantine(
166+
<StatefulForm prompt={promptWithArgs} onGetPrompt={vi.fn()} />,
167+
);
168+
const button = screen.getByRole("button", { name: "Get Prompt" });
169+
expect(button).toBeDisabled();
170+
await user.type(screen.getByPlaceholderText("Enter text..."), "hi");
171+
expect(button).not.toBeDisabled();
172+
});
173+
174+
it("allows submission when only optional arguments are blank", () => {
175+
renderWithMantine(
176+
<PromptArgumentsForm
177+
prompt={promptWithArgs}
178+
argumentValues={{ text: "Hello" }}
179+
onArgumentChange={vi.fn()}
180+
onGetPrompt={vi.fn()}
181+
/>,
182+
);
183+
// targetLanguage is required: false, so leaving it blank should
184+
// not disable submission.
185+
expect(
186+
screen.getByRole("button", { name: "Get Prompt" }),
187+
).not.toBeDisabled();
188+
});
189+
162190
it("renders without description when none is provided", () => {
163191
renderWithMantine(
164192
<PromptArgumentsForm
@@ -267,6 +295,92 @@ describe("PromptArgumentsForm", () => {
267295
});
268296
});
269297

298+
it("captures sibling values at fire time, not at schedule time", async () => {
299+
const user = userEvent.setup();
300+
const onCompleteArgument = vi
301+
.fn<
302+
(
303+
argName: string,
304+
value: string,
305+
context: Record<string, string>,
306+
) => Promise<string[]>
307+
>()
308+
.mockResolvedValue([]);
309+
310+
renderWithMantine(
311+
<StatefulForm
312+
prompt={promptWithArgs}
313+
onGetPrompt={vi.fn()}
314+
completionsSupported
315+
onCompleteArgument={onCompleteArgument}
316+
/>,
317+
);
318+
319+
// Type into "text" — this schedules a debounced completion call.
320+
await user.type(screen.getByRole("textbox", { name: /^text/ }), "h");
321+
// Before the 300ms debounce fires, update the sibling. The
322+
// text-arg fire that lands at t=300 must see the latest sibling
323+
// value, not the empty one captured at schedule time.
324+
await user.type(
325+
screen.getByRole("textbox", { name: /targetLanguage/ }),
326+
"es",
327+
);
328+
await new Promise((r) => setTimeout(r, 400));
329+
330+
// The most recent call for "text" carries the up-to-date
331+
// sibling value, even though it was scheduled before "es" was
332+
// typed. (There's also a focus-fire call when the second input
333+
// gained focus — separate stream, not asserted here.)
334+
const textCalls = onCompleteArgument.mock.calls.filter(
335+
([n]) => n === "text",
336+
);
337+
expect(textCalls.at(-1)).toEqual(["text", "h", { targetLanguage: "es" }]);
338+
});
339+
340+
it("aborts an in-flight request when a faster keystroke arrives", async () => {
341+
const user = userEvent.setup();
342+
const calls: Array<{
343+
value: string;
344+
resolve: (values: string[]) => void;
345+
}> = [];
346+
const onCompleteArgument = vi.fn(
347+
(_argName: string, value: string) =>
348+
new Promise<string[]>((resolve) => {
349+
calls.push({ value, resolve });
350+
}),
351+
);
352+
353+
renderWithMantine(
354+
<StatefulForm
355+
prompt={promptWithArgs}
356+
onGetPrompt={vi.fn()}
357+
completionsSupported
358+
onCompleteArgument={onCompleteArgument}
359+
/>,
360+
);
361+
362+
// Focus fires the first call (value=""). Type "h" → second call
363+
// after debounce. Type "i" → third call after debounce.
364+
await user.type(screen.getByRole("textbox", { name: /^text/ }), "h");
365+
await new Promise((r) => setTimeout(r, 350));
366+
await user.type(screen.getByRole("textbox", { name: /^text/ }), "i");
367+
await new Promise((r) => setTimeout(r, 350));
368+
369+
// Resolve the late "h" response — it should be dropped because
370+
// the form aborted that controller when the "hi" request started.
371+
const hi = calls.find((c) => c.value === "hi");
372+
const h = calls.find((c) => c.value === "h");
373+
expect(hi).toBeDefined();
374+
expect(h).toBeDefined();
375+
h?.resolve(["from-stale-h"]);
376+
hi?.resolve(["from-fresh-hi"]);
377+
await new Promise((r) => setTimeout(r, 0));
378+
379+
// The dropdown shows the fresh response, not the stale one.
380+
expect(await screen.findByText("from-fresh-hi")).toBeInTheDocument();
381+
expect(screen.queryByText("from-stale-h")).not.toBeInTheDocument();
382+
});
383+
270384
it("does not call onCompleteArgument when completions are unsupported", async () => {
271385
const user = userEvent.setup();
272386
const onCompleteArgument = vi.fn();

clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.tsx

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -113,30 +113,43 @@ export function PromptArgumentsForm({
113113
[onCompleteArgument],
114114
);
115115

116+
// Hold the latest argumentValues in a ref so debounced fires can read
117+
// sibling values at *fire* time, not at schedule time. Without this,
118+
// typing in arg A then arg B within the debounce window would ship
119+
// A's request with B's value stuck at its pre-keystroke state.
120+
const argumentValuesRef = useRef(argumentValues);
121+
useEffect(() => {
122+
argumentValuesRef.current = argumentValues;
123+
}, [argumentValues]);
124+
116125
// Build the `context.arguments` payload for a completion request.
117126
// Includes every prompt argument the user could fill in (with `""`
118127
// for ones they haven't typed yet) except the one being completed —
119128
// the completing arg goes in `params.argument`. Servers that
120129
// disambiguate based on co-arguments need all of them, not just
121130
// whatever the user has already typed.
122-
function buildContext(currentArg: string): Record<string, string> {
123-
const ctx: Record<string, string> = {};
124-
for (const a of promptArguments ?? []) {
125-
if (a.name === currentArg) continue;
126-
ctx[a.name] = argumentValues[a.name] ?? "";
127-
}
128-
return ctx;
129-
}
131+
const buildContext = useCallback(
132+
(currentArg: string): Record<string, string> => {
133+
const ctx: Record<string, string> = {};
134+
for (const a of promptArguments ?? []) {
135+
if (a.name === currentArg) continue;
136+
ctx[a.name] = argumentValuesRef.current[a.name] ?? "";
137+
}
138+
return ctx;
139+
},
140+
[promptArguments],
141+
);
130142

131143
function handleChange(argName: string, value: string) {
132144
onArgumentChange(argName, value);
133145
if (!useAutocomplete) return;
134-
const context = buildContext(argName);
135146
const existing = timersRef.current.get(argName);
136147
if (existing) clearTimeout(existing);
137148
const timer = setTimeout(() => {
138149
timersRef.current.delete(argName);
139-
void runCompletion(argName, value, context);
150+
// Build context at fire time so sibling values that arrived
151+
// between schedule and fire are picked up.
152+
void runCompletion(argName, value, buildContext(argName));
140153
}, COMPLETION_DEBOUNCE_MS);
141154
timersRef.current.set(argName, timer);
142155
}
@@ -151,10 +164,17 @@ export function PromptArgumentsForm({
151164
clearTimeout(existing);
152165
timersRef.current.delete(argName);
153166
}
154-
const value = argumentValues[argName] ?? "";
167+
const value = argumentValuesRef.current[argName] ?? "";
155168
void runCompletion(argName, value, buildContext(argName));
156169
}
157170

171+
// Mirror ResourceTemplatePanel: every required argument must be
172+
// filled before Get Prompt is enabled. Optional args are allowed to
173+
// stay empty; the server will treat them as absent.
174+
const canSubmit = (promptArguments ?? [])
175+
.filter((a) => a.required === true)
176+
.every((a) => (argumentValues[a.name] ?? "").length > 0);
177+
158178
return (
159179
<Stack gap="md">
160180
<PromptTitle>{title ?? name}</PromptTitle>
@@ -198,7 +218,7 @@ export function PromptArgumentsForm({
198218
</>
199219
)}
200220
<Group justify="flex-end">
201-
<Button size="sm" onClick={onGetPrompt}>
221+
<Button size="sm" disabled={!canSubmit} onClick={onGetPrompt}>
202222
Get Prompt
203223
</Button>
204224
</Group>

clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -163,25 +163,33 @@ export function ResourceTemplatePanel({
163163
[onCompleteArgument],
164164
);
165165

166+
// Hold the latest `variables` in a ref so a debounced completion
167+
// call reads sibling values at fire time, not at schedule time.
168+
// Typing in A then B within the 300ms window would otherwise ship
169+
// A's request with B's value still empty in context.
170+
const variablesRef = useRef(variables);
171+
useEffect(() => {
172+
variablesRef.current = variables;
173+
}, [variables]);
174+
175+
function buildContext(varName: string): Record<string, string> {
176+
const ctx: Record<string, string> = { ...variablesRef.current };
177+
delete ctx[varName];
178+
return ctx;
179+
}
180+
166181
function handleVariableChange(varName: string, value: string) {
167-
setVariables((prev) => {
168-
const next = { ...prev, [varName]: value };
169-
if (useAutocomplete) {
170-
// Schedule a debounced completion call. The `context` carries the
171-
// other variables' current values so the server can disambiguate
172-
// when one variable depends on another.
173-
const context: Record<string, string> = { ...next };
174-
delete context[varName];
175-
const existing = timersRef.current.get(varName);
176-
if (existing) clearTimeout(existing);
177-
const timer = setTimeout(() => {
178-
timersRef.current.delete(varName);
179-
void runCompletion(varName, value, context);
180-
}, COMPLETION_DEBOUNCE_MS);
181-
timersRef.current.set(varName, timer);
182-
}
183-
return next;
184-
});
182+
setVariables((prev) => ({ ...prev, [varName]: value }));
183+
if (!useAutocomplete) return;
184+
const existing = timersRef.current.get(varName);
185+
if (existing) clearTimeout(existing);
186+
const timer = setTimeout(() => {
187+
timersRef.current.delete(varName);
188+
// Build context at fire time so sibling updates that arrived
189+
// between schedule and fire are picked up.
190+
void runCompletion(varName, value, buildContext(varName));
191+
}, COMPLETION_DEBOUNCE_MS);
192+
timersRef.current.set(varName, timer);
185193
}
186194

187195
function handleVariableFocus(varName: string) {
@@ -196,10 +204,8 @@ export function ResourceTemplatePanel({
196204
clearTimeout(existing);
197205
timersRef.current.delete(varName);
198206
}
199-
const value = variables[varName] ?? "";
200-
const context: Record<string, string> = { ...variables };
201-
delete context[varName];
202-
void runCompletion(varName, value, context);
207+
const value = variablesRef.current[varName] ?? "";
208+
void runCompletion(varName, value, buildContext(varName));
203209
}
204210

205211
const canSubmit = variableNames.every((n) => variables[n]?.length > 0);

clients/web/src/components/screens/PromptsScreen/PromptsScreen.test.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,24 @@ describe("PromptsScreen", () => {
186186
expect(screen.queryByText("Messages")).not.toBeInTheDocument();
187187
});
188188

189+
it("re-clicking the active prompt preserves form values and does not re-fetch", async () => {
190+
const user = userEvent.setup();
191+
const onGetPrompt = vi.fn();
192+
renderWithMantine(
193+
<PromptsScreen
194+
{...baseProps}
195+
prompts={noArgPrompts}
196+
onGetPrompt={onGetPrompt}
197+
/>,
198+
);
199+
await user.click(screen.getByText("ping"));
200+
expect(onGetPrompt).toHaveBeenCalledTimes(1);
201+
// Sidebar re-click on the same prompt should be a no-op — sidebar
202+
// is navigation, ✕ is dismiss.
203+
await user.click(screen.getByText("ping"));
204+
expect(onGetPrompt).toHaveBeenCalledTimes(1);
205+
});
206+
189207
it("resets argument values when switching prompts", async () => {
190208
const user = userEvent.setup();
191209
const arglessTwoStep: Prompt[] = [

clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,11 @@ export function PromptsScreen({
127127
: undefined;
128128

129129
function handleSelectPrompt(name: string) {
130+
// Re-clicking the active prompt in the sidebar shouldn't wipe the
131+
// user's typed argument values or trigger a re-fetch — sidebar is
132+
// for navigation, ✕ is for dismiss. Closing-then-reselecting is
133+
// its own thing (the close handler clears submittedFor).
134+
if (name === selectedPromptName) return;
130135
setArgumentValues({});
131136
setSelectedPromptName(name);
132137
// Auto-fetch no-argument prompts the moment they're selected — the

0 commit comments

Comments
 (0)