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
13 changes: 1 addition & 12 deletions packages/webui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,6 @@
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./followup": {
"types": "./dist/followup.d.ts",
"import": "./dist/followup.js",
"require": "./dist/followup.cjs"
},
"./icons": {
"types": "./dist/components/icons/index.d.ts",
"import": "./dist/components/icons/index.js",
Expand All @@ -37,23 +32,17 @@
},
"scripts": {
"dev": "vite build --watch",
"build": "vite build && vite build --config vite.config.followup.ts",
"build": "vite build",
"typecheck": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx",
"lint:fix": "eslint src --ext .ts,.tsx --fix",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"peerDependencies": {
"@qwen-code/qwen-code-core": ">=0.13.1",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@qwen-code/qwen-code-core": {
"optional": true
}
},
"dependencies": {
"markdown-it": "^14.1.0"
},
Expand Down
15 changes: 2 additions & 13 deletions packages/webui/src/components/layout/InputForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,7 @@ import { CompletionMenu } from './CompletionMenu.js';
import { ContextIndicator } from './ContextIndicator.js';
import type { CompletionItem } from '../../types/completion.js';
import type { ContextUsage } from './ContextIndicator.js';
/**
* Minimal follow-up state shape used by InputForm.
* Defined locally to avoid pulling @qwen-code/qwen-code-core into the
* root entry's type declarations. The full FollowupState lives in
* '@qwen-code/webui/followup'.
*/
interface InputFormFollowupState {
/** Current suggestion text */
suggestion: string | null;
/** Whether to show suggestion */
isVisible: boolean;
}
import type { FollowupState } from '../../types/followup.js';

/**
* Edit mode display information
Expand Down Expand Up @@ -141,7 +130,7 @@ export interface InputFormProps {
/** Whether the current draft is eligible to submit */
canSubmit?: boolean;
/** Prompt suggestion state */
followupState?: InputFormFollowupState;
followupState?: FollowupState;
/** Callback to accept prompt suggestion */
onAcceptFollowup?: (method?: 'tab' | 'enter' | 'right') => void;
/** Callback to dismiss prompt suggestion */
Expand Down
19 changes: 0 additions & 19 deletions packages/webui/src/followup.ts

This file was deleted.

213 changes: 173 additions & 40 deletions packages/webui/src/hooks/useFollowupSuggestions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,187 @@
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Prompt Suggestion Hook
*
* Thin React wrapper around the framework-agnostic controller from core.
*
* Note: For browser environments, the parent component should handle
* suggestion generation and pass the results to this hook.
*/

import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import {
INITIAL_FOLLOWUP_STATE,
createFollowupController,
} from '@qwen-code/qwen-code-core';
import type { FollowupState } from '@qwen-code/qwen-code-core';
import type { FollowupState } from '../types/followup.js';
import { INITIAL_FOLLOWUP_STATE } from '../types/followup.js';

// Re-export types from core for convenience
export type { FollowupState } from '@qwen-code/qwen-code-core';
export type { FollowupState } from '../types/followup.js';

// ---------------------------------------------------------------------------
// Controller (framework-agnostic)
// ---------------------------------------------------------------------------

/** Delay before showing suggestion after response completes */
const SUGGESTION_DELAY_MS = 300;
/** Debounce lock duration to prevent rapid-fire accepts */
const ACCEPT_DEBOUNCE_MS = 100;

interface FollowupControllerOptions {
enabled?: boolean;
onStateChange: (state: FollowupState) => void;
getOnAccept?: () => ((text: string) => void) | undefined;
onOutcome?: (params: {
outcome: 'accepted' | 'ignored';
accept_method?: 'tab' | 'enter' | 'right';
time_ms: number;
suggestion_length: number;
}) => void;
}

interface FollowupControllerActions {
setSuggestion: (text: string | null) => void;
accept: (method?: 'tab' | 'enter' | 'right') => void;
dismiss: () => void;
clear: () => void;
cleanup: () => void;
}

function createFollowupController(
options: FollowupControllerOptions,
): FollowupControllerActions {
const { enabled = true, onStateChange, getOnAccept, onOutcome } = options;

let currentState: FollowupState = INITIAL_FOLLOWUP_STATE;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let accepting = false;
let acceptTimeoutId: ReturnType<typeof setTimeout> | null = null;

function applyState(next: FollowupState): void {
currentState = next;
onStateChange(next);
}

function clearTimers(): void {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (acceptTimeoutId) {
clearTimeout(acceptTimeoutId);
acceptTimeoutId = null;
}
}

const setSuggestion = (text: string | null): void => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}

if (!text) {
applyState(INITIAL_FOLLOWUP_STATE);
return;
}

if (!enabled) {
return;
}

timeoutId = setTimeout(() => {
applyState({ suggestion: text, isVisible: true, shownAt: Date.now() });
}, SUGGESTION_DELAY_MS);
};

const accept = (method?: 'tab' | 'enter' | 'right'): void => {
if (accepting) {
return;
}

if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}

accepting = true;

const text = currentState.suggestion;
const { shownAt } = currentState;
if (!text) {
accepting = false;
return;
}

try {
onOutcome?.({
outcome: 'accepted',
accept_method: method,
time_ms: shownAt > 0 ? Date.now() - shownAt : 0,
suggestion_length: text.length,
});
} catch (e: unknown) {

console.error('[followup] onOutcome callback threw:', e);
}

applyState(INITIAL_FOLLOWUP_STATE);

queueMicrotask(() => {
try {
getOnAccept?.()?.(text);
} catch (error: unknown) {

console.error('[followup] onAccept callback threw:', error);
} finally {
if (acceptTimeoutId) {
clearTimeout(acceptTimeoutId);
}
acceptTimeoutId = setTimeout(() => {
accepting = false;
}, ACCEPT_DEBOUNCE_MS);
}
});
};

const dismiss = (): void => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}

if (!currentState.isVisible && !currentState.suggestion) {
return;
}

if (currentState.isVisible && currentState.suggestion) {
try {
onOutcome?.({
outcome: 'ignored',
time_ms:
currentState.shownAt > 0 ? Date.now() - currentState.shownAt : 0,
suggestion_length: currentState.suggestion.length,
});
} catch (e: unknown) {

console.error('[followup] onOutcome callback threw:', e);
}
}

applyState(INITIAL_FOLLOWUP_STATE);
};

const clear = (): void => {
clearTimers();
accepting = false;
applyState(INITIAL_FOLLOWUP_STATE);
};

const cleanup = (): void => {
clearTimers();
accepting = false;
};

return { setSuggestion, accept, dismiss, clear, cleanup };
}

// ---------------------------------------------------------------------------
// React hook
// ---------------------------------------------------------------------------

/**
* Options for the hook
*/
export interface UseFollowupSuggestionsOptions {
/** Whether the feature is enabled */
enabled?: boolean;
/** Callback when suggestion is accepted */
onAccept?: (suggestion: string) => void;
/** Callback when a suggestion outcome is determined */
onOutcome?: (params: {
outcome: 'accepted' | 'ignored';
accept_method?: 'tab' | 'enter' | 'right';
Expand All @@ -38,45 +191,27 @@ export interface UseFollowupSuggestionsOptions {
}) => void;
}

/**
* Result returned by the hook
*/
export interface UseFollowupSuggestionsReturn {
/** Current state */
state: FollowupState;
/** Get current placeholder text */
getPlaceholder: (defaultPlaceholder: string) => string;
/** Set suggestion text (called by parent component) */
setSuggestion: (text: string | null) => void;
/** Accept the current suggestion */
accept: (method?: 'tab' | 'enter' | 'right') => void;
/** Dismiss the current suggestion */
dismiss: () => void;
/** Clear all state */
clear: () => void;
}

/**
* Hook for managing prompt suggestions in the Web UI.
*
* Delegates all timer/debounce/state logic to the shared
* `createFollowupController` from core. Adds a `getPlaceholder`
* helper specific to the WebUI input form.
*/
export function useFollowupSuggestions(
options: UseFollowupSuggestionsOptions = {},
): UseFollowupSuggestionsReturn {
const { enabled = true, onAccept, onOutcome } = options;

const [state, setState] = useState<FollowupState>(INITIAL_FOLLOWUP_STATE);

// Keep mutable refs so the controller always sees the latest callbacks
const onAcceptRef = useRef(onAccept);
onAcceptRef.current = onAccept;
const onOutcomeRef = useRef(onOutcome);
onOutcomeRef.current = onOutcome;

// Create the controller once — it is stable across renders
const controller = useMemo(
() =>
createFollowupController({
Expand All @@ -88,15 +223,13 @@ export function useFollowupSuggestions(
[enabled],
);

// Clear state when disabled; clean up timers on unmount
useEffect(() => {
if (!enabled) {
controller.clear();
}
return () => controller.cleanup();
}, [controller, enabled]);

// WebUI-specific helper: resolves placeholder text
const getPlaceholder = useCallback(
(defaultPlaceholder: string) => {
if (state.isVisible && state.suggestion) {
Expand Down
8 changes: 6 additions & 2 deletions packages/webui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,8 +231,12 @@ export { StopIcon } from './components/icons/StopIcon';
// Hooks
export { useTheme } from './hooks/useTheme';
export { useLocalStorage } from './hooks/useLocalStorage';
// NOTE: useFollowupSuggestions is exported from '@qwen-code/webui/followup'
// subpath to avoid forcing all consumers to install @qwen-code/qwen-code-core.
export { useFollowupSuggestions } from './hooks/useFollowupSuggestions';
export type {
FollowupState,
UseFollowupSuggestionsOptions,
UseFollowupSuggestionsReturn,
} from './hooks/useFollowupSuggestions';

// Types
export type { Theme } from './types/theme';
Expand Down
20 changes: 20 additions & 0 deletions packages/webui/src/types/followup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

export interface FollowupState {
/** Current suggestion text */
suggestion: string | null;
/** Whether to show suggestion */
isVisible: boolean;
/** Timestamp when suggestion was shown (for telemetry) */
shownAt: number;
}

export const INITIAL_FOLLOWUP_STATE: Readonly<FollowupState> = Object.freeze({
suggestion: null,
isVisible: false,
shownAt: 0,
});
Loading
Loading