diff --git a/packages/webui/package.json b/packages/webui/package.json index de531b80d..6d1f8e513 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -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", @@ -37,7 +32,7 @@ }, "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", @@ -45,15 +40,9 @@ "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" }, diff --git a/packages/webui/src/components/layout/InputForm.tsx b/packages/webui/src/components/layout/InputForm.tsx index f34436843..86ffd4ef8 100644 --- a/packages/webui/src/components/layout/InputForm.tsx +++ b/packages/webui/src/components/layout/InputForm.tsx @@ -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 @@ -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 */ diff --git a/packages/webui/src/followup.ts b/packages/webui/src/followup.ts deleted file mode 100644 index 028159051..000000000 --- a/packages/webui/src/followup.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * Prompt Suggestion Subpath Entry - * - * Separated from the root entry to avoid forcing all @qwen-code/webui - * consumers to install @qwen-code/qwen-code-core as a dependency. - * - * Usage: import { useFollowupSuggestions } from '@qwen-code/webui/followup'; - */ - -export { useFollowupSuggestions } from './hooks/useFollowupSuggestions'; -export type { - FollowupState, - UseFollowupSuggestionsOptions, - UseFollowupSuggestionsReturn, -} from './hooks/useFollowupSuggestions'; diff --git a/packages/webui/src/hooks/useFollowupSuggestions.ts b/packages/webui/src/hooks/useFollowupSuggestions.ts index 60fa84ce3..ee5f01003 100644 --- a/packages/webui/src/hooks/useFollowupSuggestions.ts +++ b/packages/webui/src/hooks/useFollowupSuggestions.ts @@ -2,34 +2,27 @@ * @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'; -/** - * Options for the hook - */ -export interface UseFollowupSuggestionsOptions { - /** Whether the feature is enabled */ +// --------------------------------------------------------------------------- +// 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; - /** Callback when suggestion is accepted */ - onAccept?: (suggestion: string) => void; - /** Callback when a suggestion outcome is determined */ + onStateChange: (state: FollowupState) => void; + getOnAccept?: () => ((text: string) => void) | undefined; onOutcome?: (params: { outcome: 'accepted' | 'ignored'; accept_method?: 'tab' | 'enter' | 'right'; @@ -38,31 +31,175 @@ 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) */ +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 | null = null; + let accepting = false; + let acceptTimeoutId: ReturnType | 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 +// --------------------------------------------------------------------------- + +export interface UseFollowupSuggestionsOptions { + enabled?: boolean; + onAccept?: (suggestion: string) => void; + onOutcome?: (params: { + outcome: 'accepted' | 'ignored'; + accept_method?: 'tab' | 'enter' | 'right'; + time_ms: number; + suggestion_length: number; + }) => void; +} + +export interface UseFollowupSuggestionsReturn { + state: FollowupState; + getPlaceholder: (defaultPlaceholder: string) => string; 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 { @@ -70,13 +207,11 @@ export function useFollowupSuggestions( const [state, setState] = useState(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({ @@ -88,7 +223,6 @@ export function useFollowupSuggestions( [enabled], ); - // Clear state when disabled; clean up timers on unmount useEffect(() => { if (!enabled) { controller.clear(); @@ -96,7 +230,6 @@ export function useFollowupSuggestions( return () => controller.cleanup(); }, [controller, enabled]); - // WebUI-specific helper: resolves placeholder text const getPlaceholder = useCallback( (defaultPlaceholder: string) => { if (state.isVisible && state.suggestion) { diff --git a/packages/webui/src/index.ts b/packages/webui/src/index.ts index f0b6807ef..cb491f138 100644 --- a/packages/webui/src/index.ts +++ b/packages/webui/src/index.ts @@ -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'; diff --git a/packages/webui/src/types/followup.ts b/packages/webui/src/types/followup.ts new file mode 100644 index 000000000..1d8b17c8d --- /dev/null +++ b/packages/webui/src/types/followup.ts @@ -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 = Object.freeze({ + suggestion: null, + isVisible: false, + shownAt: 0, +}); diff --git a/packages/webui/vite.config.followup.ts b/packages/webui/vite.config.followup.ts deleted file mode 100644 index 8040c545d..000000000 --- a/packages/webui/vite.config.followup.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * Separate Vite config for the @qwen-code/webui/followup subpath entry. - * - * Built independently so that the root entry (vite.config.ts) stays free - * of @qwen-code/qwen-code-core and can retain UMD output. - */ - -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; -import dts from 'vite-plugin-dts'; -import { resolve } from 'path'; - -export default defineConfig({ - plugins: [ - react(), - dts({ - include: ['src/followup.ts', 'src/hooks/useFollowupSuggestions.ts'], - outDir: 'dist', - rollupTypes: false, - // Do not insert types entry — avoid clobbering the main build's index.d.ts - insertTypesEntry: false, - }), - ], - build: { - lib: { - entry: resolve(__dirname, 'src/followup.ts'), - formats: ['es', 'cjs'], - fileName: (format) => { - if (format === 'es') return 'followup.js'; - if (format === 'cjs') return 'followup.cjs'; - return 'followup.js'; - }, - }, - outDir: 'dist', - emptyOutDir: false, - rollupOptions: { - external: [ - 'react', - 'react-dom', - 'react/jsx-runtime', - '@qwen-code/qwen-code-core', - ], - }, - sourcemap: true, - minify: false, - cssCodeSplit: false, - }, -}); diff --git a/packages/webui/vite.config.ts b/packages/webui/vite.config.ts index b85d1ad2c..9a571eab3 100644 --- a/packages/webui/vite.config.ts +++ b/packages/webui/vite.config.ts @@ -18,10 +18,6 @@ import { resolve } from 'path'; * - UMD: dist/index.umd.js (for CDN usage) * - TypeScript declarations: dist/index.d.ts * - CSS: dist/styles.css (optional styles) - * - * The followup subpath (@qwen-code/webui/followup) is built separately - * via vite.config.followup.ts so that the root entry stays free of - * @qwen-code/qwen-code-core dependencies. */ export default defineConfig({ plugins: [