Skip to content

Commit 5408e16

Browse files
cliffhallclaude
andcommitted
Fire prompt completions on focus and send every sibling in context
Two fixes to the PromptArgumentsForm autocomplete: - Focusing an argument input now fires completion/complete immediately (handleFocus) — the dropdown is populated as soon as the user clicks in, not only after they start typing. Any in-flight debounce timer for the same arg is cancelled so a stale keystroke request can't overwrite the fresh focus response. - The completion context now includes every declared prompt argument (with "" for ones the user hasn't typed into yet), minus the one being completed. Previously the context only carried args the user had touched, so servers that disambiguate based on co-arguments couldn't see the full picture on the first keystroke. Tests cover both: a focus-only path that fires before any keystroke, and a typing path that asserts the empty sibling is sent through. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6d75874 commit 5408e16

2 files changed

Lines changed: 72 additions & 11 deletions

File tree

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

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,37 @@ describe("PromptArgumentsForm", () => {
173173
});
174174

175175
describe("completions", () => {
176+
it("fires a completion immediately on focus before any keystroke", async () => {
177+
const user = userEvent.setup();
178+
const onCompleteArgument = vi
179+
.fn<
180+
(
181+
argName: string,
182+
value: string,
183+
context: Record<string, string>,
184+
) => Promise<string[]>
185+
>()
186+
.mockResolvedValue(["alpha", "alphabet"]);
187+
188+
renderWithMantine(
189+
<StatefulForm
190+
prompt={promptWithArgs}
191+
onGetPrompt={vi.fn()}
192+
completionsSupported
193+
onCompleteArgument={onCompleteArgument}
194+
/>,
195+
);
196+
197+
await user.click(screen.getByRole("textbox", { name: /^text/ }));
198+
// No debounce on focus — the call fires synchronously off the
199+
// focus handler. A microtask is enough for the response to settle.
200+
await new Promise((r) => setTimeout(r, 0));
201+
expect(onCompleteArgument).toHaveBeenCalledWith("text", "", {
202+
targetLanguage: "",
203+
});
204+
expect(await screen.findByText("alpha")).toBeInTheDocument();
205+
});
206+
176207
it("calls onCompleteArgument (debounced) and surfaces values when supported", async () => {
177208
const user = userEvent.setup();
178209
const onCompleteArgument = vi
@@ -196,13 +227,16 @@ describe("PromptArgumentsForm", () => {
196227

197228
await user.type(screen.getByRole("textbox", { name: /^text/ }), "al");
198229
await new Promise((r) => setTimeout(r, 400));
199-
expect(onCompleteArgument).toHaveBeenCalled();
200-
expect(onCompleteArgument).toHaveBeenLastCalledWith("text", "al", {});
230+
// The last call carries the typed value and the context for the
231+
// other (still empty) sibling.
232+
expect(onCompleteArgument).toHaveBeenLastCalledWith("text", "al", {
233+
targetLanguage: "",
234+
});
201235
expect(await screen.findByText("alpha")).toBeInTheDocument();
202236
expect(screen.getByText("alphabet")).toBeInTheDocument();
203237
});
204238

205-
it("passes sibling argument values as completion context", async () => {
239+
it("sends every sibling argument in context, including the unset ones", async () => {
206240
const user = userEvent.setup();
207241
const onCompleteArgument = vi
208242
.fn<
@@ -226,7 +260,8 @@ describe("PromptArgumentsForm", () => {
226260

227261
await user.type(screen.getByRole("textbox", { name: /^text/ }), "h");
228262
await new Promise((r) => setTimeout(r, 400));
229-
// The completing arg ("text") is excluded from context; siblings ride along.
263+
// The completing arg ("text") is excluded from context; every
264+
// other declared argument rides along — even ones still empty.
230265
expect(onCompleteArgument).toHaveBeenLastCalledWith("text", "h", {
231266
targetLanguage: "es",
232267
});
@@ -243,6 +278,8 @@ describe("PromptArgumentsForm", () => {
243278
onCompleteArgument={onCompleteArgument}
244279
/>,
245280
);
281+
// Focus the input first, then type — neither should trigger a call.
282+
await user.click(screen.getByPlaceholderText("Enter text..."));
246283
await user.type(screen.getByPlaceholderText("Enter text..."), "ab");
247284
await new Promise((r) => setTimeout(r, 400));
248285
expect(onCompleteArgument).not.toHaveBeenCalled();

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

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -113,16 +113,25 @@ export function PromptArgumentsForm({
113113
[onCompleteArgument],
114114
);
115115

116+
// Build the `context.arguments` payload for a completion request.
117+
// Includes every prompt argument the user could fill in (with `""`
118+
// for ones they haven't typed yet) except the one being completed —
119+
// the completing arg goes in `params.argument`. Servers that
120+
// disambiguate based on co-arguments need all of them, not just
121+
// 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+
}
130+
116131
function handleChange(argName: string, value: string) {
117132
onArgumentChange(argName, value);
118133
if (!useAutocomplete) return;
119-
// The completing arg is excluded from context so the server can
120-
// disambiguate when one argument depends on another.
121-
const context: Record<string, string> = {
122-
...argumentValues,
123-
[argName]: value,
124-
};
125-
delete context[argName];
134+
const context = buildContext(argName);
126135
const existing = timersRef.current.get(argName);
127136
if (existing) clearTimeout(existing);
128137
const timer = setTimeout(() => {
@@ -132,6 +141,20 @@ export function PromptArgumentsForm({
132141
timersRef.current.set(argName, timer);
133142
}
134143

144+
function handleFocus(argName: string) {
145+
if (!useAutocomplete) return;
146+
// Fire immediately so the dropdown isn't empty when the user first
147+
// clicks in. Cancel any pending debounce so a stale keystroke
148+
// request doesn't overwrite this fresher one.
149+
const existing = timersRef.current.get(argName);
150+
if (existing) {
151+
clearTimeout(existing);
152+
timersRef.current.delete(argName);
153+
}
154+
const value = argumentValues[argName] ?? "";
155+
void runCompletion(argName, value, buildContext(argName));
156+
}
157+
135158
return (
136159
<Stack gap="md">
137160
<PromptTitle>{title ?? name}</PromptTitle>
@@ -155,6 +178,7 @@ export function PromptArgumentsForm({
155178
// suggestions client-side.
156179
filter={({ options }) => options}
157180
onChange={(value) => handleChange(arg.name, value)}
181+
onFocus={() => handleFocus(arg.name)}
158182
/>
159183
) : (
160184
<TextInput

0 commit comments

Comments
 (0)