mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
fix(cli): clear promptSuggestion on submit and accept paths
Addresses doudouOUC review on #5145. Since abortPromptSuggestion was changed to preserve `promptSuggestion` for type-then-delete restore, the submit and accept paths leaked stale suggestion text: - handleSubmitAndClear only called followup.dismiss(); after a synchronous command (/clear, /help) that never triggers AppContainer's streaming transition, the placeholder kept showing the old suggestion. - Tab/Right/Enter accept never cleared the prop, so clearing the buffer without submitting (Ctrl+U) made the accepted suggestion reappear as a ghost placeholder. Both now call onPromptSuggestionDismiss?.() after the followup action. Also reuse the availableSuggestion single-source-of-truth in hasTabConsumer instead of an inlined parallel expression, and add useFollowupSuggestions tests asserting the accept_source guard suppresses time_to_first_keystroke_ms on fallback accepts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
bb6eb3726e
commit
04fcffd1c2
2 changed files with 118 additions and 4 deletions
|
|
@ -395,8 +395,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
// entry rather than advancing from whatever index the user picked.
|
||||
resetHistoryNavRef.current();
|
||||
|
||||
// Dismiss follow-up suggestion after submit
|
||||
// Dismiss follow-up suggestion after submit. `followup.dismiss()` only
|
||||
// resets the controller; `onPromptSuggestionDismiss` also clears the
|
||||
// persisted `promptSuggestion` prop so the placeholder doesn't leak a
|
||||
// stale suggestion after synchronous commands (e.g. /clear, /help) that
|
||||
// never trigger the streaming-transition effect in AppContainer.
|
||||
followup.dismiss();
|
||||
onPromptSuggestionDismiss?.();
|
||||
|
||||
// Clear attachments after submit
|
||||
setAttachments([]);
|
||||
|
|
@ -418,6 +423,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
config,
|
||||
pendingPastes,
|
||||
followup,
|
||||
onPromptSuggestionDismiss,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -1007,6 +1013,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
// suggestion (e.g. after type-then-delete), `fallbackText` carries the
|
||||
// still-available placeholder text so telemetry is logged either way.
|
||||
followup.accept('tab', { fallbackText: promptSuggestion ?? undefined });
|
||||
// Clear the persisted `promptSuggestion` prop too, otherwise it survives
|
||||
// the accept and reappears as a ghost placeholder once the buffer is
|
||||
// cleared without submitting (e.g. Ctrl+U).
|
||||
onPromptSuggestionDismiss?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -1021,6 +1031,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
followup.accept('right', {
|
||||
fallbackText: promptSuggestion ?? undefined,
|
||||
});
|
||||
onPromptSuggestionDismiss?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -1277,6 +1288,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
followup.accept('enter', {
|
||||
fallbackText: promptSuggestion ?? undefined,
|
||||
});
|
||||
onPromptSuggestionDismiss?.();
|
||||
return true;
|
||||
}
|
||||
if (buffer.text.trim()) {
|
||||
|
|
@ -1614,9 +1626,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
// this drops to false; deleting back to empty restores it.
|
||||
const hasTabConsumer =
|
||||
shouldShowSuggestions ||
|
||||
(buffer.text.length === 0 &&
|
||||
((followup.state.isVisible && Boolean(followup.state.suggestion)) ||
|
||||
Boolean(promptSuggestion))) ||
|
||||
(buffer.text.length === 0 && Boolean(availableSuggestion)) ||
|
||||
Boolean(completion.midInputGhostText?.acceptText);
|
||||
|
||||
// Narrow signal — autocomplete dropdown only. Composer hides Footer /
|
||||
|
|
|
|||
104
packages/cli/src/ui/hooks/useFollowupSuggestions.test.tsx
Normal file
104
packages/cli/src/ui/hooks/useFollowupSuggestions.test.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Config, PromptSuggestionEvent } from '@qwen-code/qwen-code-core';
|
||||
|
||||
const { mockLogPromptSuggestion } = vi.hoisted(() => ({
|
||||
mockLogPromptSuggestion: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...actual,
|
||||
logPromptSuggestion: mockLogPromptSuggestion,
|
||||
};
|
||||
});
|
||||
|
||||
import { useFollowupSuggestionsCLI } from './useFollowupSuggestions.js';
|
||||
|
||||
// Mirror of SUGGESTION_DELAY_MS in core's followupState controller.
|
||||
const SUGGESTION_DELAY_MS = 300;
|
||||
const config = {} as Config;
|
||||
|
||||
function acceptedEvent(): PromptSuggestionEvent | undefined {
|
||||
const call = mockLogPromptSuggestion.mock.calls.find(
|
||||
([, event]) => (event as PromptSuggestionEvent).outcome === 'accepted',
|
||||
);
|
||||
return call?.[1] as PromptSuggestionEvent | undefined;
|
||||
}
|
||||
|
||||
describe('useFollowupSuggestionsCLI telemetry', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mockLogPromptSuggestion.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('omits time_to_first_keystroke_ms on fallback accepts even with stale shown/keystroke refs', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFollowupSuggestionsCLI({ enabled: true, config }),
|
||||
);
|
||||
|
||||
// Show a live suggestion so prevShownAtRef + firstKeystrokeAtRef get set
|
||||
// from this turn — exactly the stale state that would corrupt a later
|
||||
// fallback accept if the accept_source guard were missing.
|
||||
act(() => {
|
||||
result.current.setSuggestion('run the tests');
|
||||
vi.advanceTimersByTime(SUGGESTION_DELAY_MS);
|
||||
});
|
||||
expect(result.current.state.isVisible).toBe(true);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(50);
|
||||
result.current.recordKeystroke();
|
||||
});
|
||||
|
||||
// Drop the live suggestion and accept via fallback in the same tick, before
|
||||
// the ref-reset effect runs. accept_source is 'fallback' (no live
|
||||
// suggestion), so the keystroke delta must be suppressed.
|
||||
act(() => {
|
||||
result.current.setSuggestion(null);
|
||||
result.current.accept('tab', { fallbackText: 'run the tests' });
|
||||
});
|
||||
|
||||
const event = acceptedEvent();
|
||||
expect(event).toBeDefined();
|
||||
expect(event?.accept_source).toBe('fallback');
|
||||
expect(event?.time_to_first_keystroke_ms).toBeUndefined();
|
||||
});
|
||||
|
||||
it('includes time_to_first_keystroke_ms on live accepts', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFollowupSuggestionsCLI({ enabled: true, config }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setSuggestion('run the tests');
|
||||
vi.advanceTimersByTime(SUGGESTION_DELAY_MS);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(50);
|
||||
result.current.recordKeystroke();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.accept('tab');
|
||||
});
|
||||
|
||||
const event = acceptedEvent();
|
||||
expect(event).toBeDefined();
|
||||
expect(event?.accept_source).toBe('live');
|
||||
expect(event?.time_to_first_keystroke_ms).toBe(50);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue