From f2fa8244a71705e5c5f1b8eeac5fd167ea66188e Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Fri, 16 Oct 2026 11:15:26 +0800 Subject: [PATCH] fix: apply prompt suggestion toggle immediately --- .../agentic/execution/prompt_suggestion.rs | 182 +++++++++++++++--- .../pages/ai/PromptSuggestionSection.test.tsx | 14 +- .../pages/ai/PromptSuggestionSection.tsx | 6 + .../src/flow_chat/components/ChatInput.tsx | 3 + .../usePromptSuggestionsEnabled.test.tsx | 92 +++++++++ .../hooks/usePromptSuggestionsEnabled.ts | 19 ++ .../store/promptSuggestionStore.test.ts | 21 +- .../flow_chat/store/promptSuggestionStore.ts | 29 ++- 8 files changed, 330 insertions(+), 36 deletions(-) create mode 100644 src/web-ui/src/flow_chat/hooks/usePromptSuggestionsEnabled.test.tsx create mode 100644 src/web-ui/src/flow_chat/hooks/usePromptSuggestionsEnabled.ts diff --git a/src/crates/assembly/core/src/agentic/execution/prompt_suggestion.rs b/src/crates/assembly/core/src/agentic/execution/prompt_suggestion.rs index 373e1a4801..6d6b31acd6 100644 --- a/src/crates/assembly/core/src/agentic/execution/prompt_suggestion.rs +++ b/src/crates/assembly/core/src/agentic/execution/prompt_suggestion.rs @@ -19,6 +19,7 @@ use crate::infrastructure::ai::reasoning_catalog::{ load_models_dev_reasoning_catalog_without_refresh, project_model_reasoning_catalog, }; use crate::infrastructure::ai::AIClient; +use crate::service::config::{subscribe_config_updates, ConfigUpdateEvent}; use crate::util::types::{Message as AIMessage, ToolDefinition}; use log::{debug, info, warn}; use openbitfun_agent_runtime::prompt_suggestion::{ @@ -132,35 +133,70 @@ async fn generate_prompt_suggestion( request: PromptSuggestionRequest, session_manager: Arc, ) -> anyhow::Result> { - if !prompt_suggestion_enabled().await { - debug!( - "Prompt suggestion disabled by configuration: session_id={}, turn_id={}", - request.session_id, request.turn_id - ); + // Subscribe before reading enablement so a saved setting cannot fall between + // the initial check and the in-flight request's cancellation boundary. + let Some(updates) = subscribe_config_updates() else { return Ok(None); - } - - let ai_client = suggestion_client(&request).await; - let ai_messages = build_prompt_suggestion_messages(&request).await?; - - let trace_dir = session_manager - .persistent_model_exchange_trace_dir(&request.session_id) + }; + generate_while_enabled(updates, prompt_suggestion_enabled, async { + let ai_client = suggestion_client(&request).await; + let ai_messages = build_prompt_suggestion_messages(&request).await?; + + let trace_dir = session_manager + .persistent_model_exchange_trace_dir(&request.session_id) + .await; + let trace = prepare_model_exchange_trace_for_workspace( + &request.session_id, + &request.turn_id, + request.workspace.as_ref(), + trace_dir.as_deref(), + ModelExchangeTraceOperation { + kind: "prompt_suggestion", + id: &request.turn_id, + trigger: None, + }, + request.ai_client.as_ref(), + ) .await; - let trace = prepare_model_exchange_trace_for_workspace( - &request.session_id, - &request.turn_id, - request.workspace.as_ref(), - trace_dir.as_deref(), - ModelExchangeTraceOperation { - kind: "prompt_suggestion", - id: &request.turn_id, - trigger: None, - }, - request.ai_client.as_ref(), - ) - .await; - request_prompt_suggestion_response(&request, ai_client, ai_messages, trace).await + request_prompt_suggestion_response(&request, ai_client, ai_messages, trace).await + }) + .await +} + +/// Drop both the active provider request and any fallback when the host disables +/// suggestions. The same config notifications cover local, peer and relay hosts. +async fn generate_while_enabled( + mut updates: tokio::sync::broadcast::Receiver, + enabled: E, + generation: F, +) -> anyhow::Result> +where + F: Future>>, + E: Fn() -> EF, + EF: Future, +{ + if !enabled().await { + return Ok(None); + } + tokio::pin!(generation); + loop { + tokio::select! { + biased; + update = updates.recv() => { + if matches!(update, Err(tokio::sync::broadcast::error::RecvError::Closed)) { + return Ok(None); + } + if !enabled().await { + return Ok(None); + } + } + result = &mut generation => { + // Also fence a completed response against a simultaneous save. + return if enabled().await { result } else { Ok(None) }; + } + } + } } async fn request_prompt_suggestion_response( @@ -327,6 +363,100 @@ mod tests { assert_eq!(queue.stats().await.total_enqueued, 0); } + #[tokio::test] + async fn disabling_suggestions_drops_inflight_request_without_publishing() { + use std::sync::atomic::{AtomicBool, Ordering}; + + struct DropWitness(Arc); + impl Drop for DropWitness { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + let enabled = Arc::new(AtomicBool::new(true)); + let check = enabled.clone(); + let dropped = Arc::new(AtomicBool::new(false)); + let witness = DropWitness(dropped.clone()); + let (updates, receiver) = tokio::sync::broadcast::channel(4); + let (started, ready) = tokio::sync::oneshot::channel(); + let runtime = PromptSuggestionRuntime::new(); + let queue = Arc::new(EventQueue::new(EventQueueConfig::default())); + let task = spawn_prompt_suggestion_generation( + &runtime, + queue.clone(), + "session-1".into(), + "turn-1".into(), + generate_while_enabled( + receiver, + move || std::future::ready(check.load(Ordering::SeqCst)), + async move { + let _witness = witness; + started.send(()).unwrap(); + std::future::pending().await + }, + ), + ); + ready.await.unwrap(); + enabled.store(false, Ordering::SeqCst); + updates.send(ConfigUpdateEvent::AppUpdated).unwrap(); + tokio::time::timeout( + Duration::from_secs(1), + assert_silent_completion(&runtime, &queue, task), + ) + .await + .expect("disabling must not wait for the provider response"); + assert!(dropped.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn disabled_generation_is_not_polled_and_reenable_allows_a_new_request() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let enabled = AtomicBool::new(false); + let (updates, receiver) = tokio::sync::broadcast::channel(4); + let result = generate_while_enabled( + receiver, + || std::future::ready(enabled.load(Ordering::SeqCst)), + async { panic!("disabled suggestions must not call the provider") }, + ) + .await + .unwrap(); + assert_eq!(result, None); + + enabled.store(true, Ordering::SeqCst); + let receiver = updates.subscribe(); + updates.send(ConfigUpdateEvent::EditorUpdated).unwrap(); + assert_eq!( + generate_while_enabled( + receiver, + || std::future::ready(enabled.load(Ordering::SeqCst)), + async { Ok(Some("Run the tests".into())) }, + ) + .await + .unwrap(), + Some("Run the tests".into()), + ); + } + + #[tokio::test] + async fn completed_prediction_rechecks_enablement_before_returning() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let enabled = AtomicBool::new(true); + let (_updates, receiver) = tokio::sync::broadcast::channel(4); + let result = generate_while_enabled( + receiver, + || std::future::ready(enabled.load(Ordering::SeqCst)), + async { + enabled.store(false, Ordering::SeqCst); + Ok(Some("Late suggestion".into())) + }, + ) + .await + .unwrap(); + assert_eq!(result, None); + } + #[tokio::test(start_paused = true)] async fn timeout_at_eighty_seconds_drops_generation_and_emits_nothing() { use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/PromptSuggestionSection.test.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/PromptSuggestionSection.test.tsx index 411f8b3a49..4481459abe 100644 --- a/src/web-ui/src/app/scenes/settings/pages/ai/PromptSuggestionSection.test.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/ai/PromptSuggestionSection.test.tsx @@ -3,6 +3,7 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import PromptSuggestionSection from './PromptSuggestionSection'; +import { promptSuggestionKey, usePromptSuggestionStore } from '@/flow_chat/store/promptSuggestionStore'; const mocks = vi.hoisted(() => ({ get: vi.fn(), save: vi.fn(), error: vi.fn(), success: vi.fn() })); vi.mock('@/infrastructure/config/services/AIExperienceConfigService', () => ({ @@ -25,11 +26,16 @@ beforeEach(() => { vi.clearAllMocks(); mocks.get.mockResolvedValue({ enable_prompt_suggestion: true }); mocks.save.mockResolvedValue(undefined); + usePromptSuggestionStore.setState({ suggestions: {}, enabledBySurface: {} }); container = document.createElement('div'); document.body.append(container); root = createRoot(container); }); -afterEach(async () => { await act(async () => root.unmount()); container.remove(); }); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + usePromptSuggestionStore.setState({ suggestions: {}, enabledBySurface: {} }); +}); describe('Prompt suggestion settings', () => { it('keeps older hosts without the optional setting explicitly unsupported', async () => { @@ -41,15 +47,21 @@ describe('Prompt suggestion settings', () => { }); it('saves only the prompt suggestion field and keeps the previous value on failure', async () => { + const candidate = { suggestion: 'Run the tests', sourceTurnId: 't1' }; + usePromptSuggestionStore.getState().setSuggestion('session', candidate); await act(async () => root.render()); expect(toggle().checked).toBe(true); mocks.save.mockRejectedValueOnce(new Error('Host unavailable')); await act(async () => toggle().click()); expect(toggle().checked).toBe(true); expect(mocks.error).toHaveBeenCalledWith('promptSuggestion.messages.saveFailed'); + expect(usePromptSuggestionStore.getState().suggestions[promptSuggestionKey('session')]).toEqual(candidate); await act(async () => toggle().click()); expect(mocks.save).toHaveBeenLastCalledWith({ enable_prompt_suggestion: false }); expect(toggle().checked).toBe(false); + expect(usePromptSuggestionStore.getState().suggestions).toEqual({}); + usePromptSuggestionStore.getState().setSuggestion('session', candidate); + expect(usePromptSuggestionStore.getState().suggestions).toEqual({}); expect(mocks.success).toHaveBeenCalledWith('promptSuggestion.messages.saveSuccess'); }); diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/PromptSuggestionSection.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/PromptSuggestionSection.tsx index 57d6bb5d64..e5c51c5322 100644 --- a/src/web-ui/src/app/scenes/settings/pages/ai/PromptSuggestionSection.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/ai/PromptSuggestionSection.tsx @@ -5,6 +5,8 @@ import { createLogger } from '@/shared/utils/logger'; import { Switch } from '@openbitfun/ui'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useI18n } from '@/infrastructure/i18n'; +import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; +import { usePromptSuggestionStore } from '@/flow_chat/store/promptSuggestionStore'; const log = createLogger('PromptSuggestionSection'); @@ -41,10 +43,14 @@ export default function PromptSuggestionSection() { const handleToggle = async (next: boolean) => { if (saveInFlight.current || enabled === undefined) return; + const scope = getActiveSurfaceScope(); saveInFlight.current = true; setSaving(true); try { await aiExperienceConfigService.saveSettings({ enable_prompt_suggestion: next }); + if (!scope.isCurrent()) return; + // The composer may be unmounted while Settings is open. + usePromptSuggestionStore.getState().setEnabled(next, scope.surfaceId); setEnabled(next); notification.success(t('promptSuggestion.messages.saveSuccess')); } catch (error) { diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 157a0f8809..667998bc5f 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -35,6 +35,7 @@ import { SessionExecutionEvent, SessionExecutionState } from '../state-machine/t import { ModelSelector, type ModelSelectorAvailability } from './ModelSelector'; import { FlowChatStore } from '../store/FlowChatStore'; import { useAcpPlan } from '../hooks/useAcpPlan'; +import { usePromptSuggestionsEnabled } from '../hooks/usePromptSuggestionsEnabled'; import { filterSlashCommands, useAcpSlashCommands } from '../hooks/useAcpSlashCommands'; import { acpSessionRef, acpSlashCommandText } from '../utils/acpSession'; import { AcpPlanPanel } from './AcpPlanPanel'; @@ -830,7 +831,9 @@ export const ChatInput: React.FC = ({ : undefined )); const latestSuggestionTurn = effectiveTargetSession?.dialogTurns.at(-1); + const promptSuggestionsEnabled = usePromptSuggestionsEnabled(); const promptSuggestion = visiblePromptSuggestion(storedPromptSuggestion, { + enabled: promptSuggestionsEnabled, editorEmpty: showPlaceholder, value: inputState.value, canCompose: targetCanCompose, diff --git a/src/web-ui/src/flow_chat/hooks/usePromptSuggestionsEnabled.test.tsx b/src/web-ui/src/flow_chat/hooks/usePromptSuggestionsEnabled.test.tsx new file mode 100644 index 0000000000..bea06a58bc --- /dev/null +++ b/src/web-ui/src/flow_chat/hooks/usePromptSuggestionsEnabled.test.tsx @@ -0,0 +1,92 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { activateSurface } from '@/infrastructure/peer-device/deviceSurface'; +import { usePromptSuggestionsEnabled } from './usePromptSuggestionsEnabled'; +import { promptSuggestionKey, usePromptSuggestionStore, visiblePromptSuggestion } from '../store/promptSuggestionStore'; + +const service = vi.hoisted(() => ({ + cached: vi.fn(), read: vi.fn(), listeners: new Set<(value: unknown) => void>(), +})); +vi.mock('@/infrastructure/config/services/AIExperienceConfigService', () => ({ aiExperienceConfigService: { + getCachedSettings: service.cached, + getSettingsAsync: service.read, + addChangeListener: (listener: (value: unknown) => void) => { + service.listeners.add(listener); + return () => service.listeners.delete(listener); + }, +} })); + +const candidate = { suggestion: 'Run the tests', sourceTurnId: 't1' }; +function Composer() { + const enabled = usePromptSuggestionsEnabled(); + const stored = usePromptSuggestionStore(state => state.suggestions[promptSuggestionKey('session')]); + return
{visiblePromptSuggestion(stored, { + enabled, editorEmpty: true, value: '', canCompose: true, processing: false, + latestTurnId: 't1', latestTurnStatus: 'completed', + })}
; +} + +describe('prompt suggestion preference', () => { + let root: Root; + let container: HTMLDivElement; + beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + activateSurface('local'); + service.cached.mockReset().mockReturnValue({ enable_prompt_suggestion: true }); + service.read.mockReset().mockResolvedValue({ enable_prompt_suggestion: true }); + service.listeners.clear(); + usePromptSuggestionStore.setState({ suggestions: {}, enabledBySurface: {} }); + container = document.createElement('div'); + root = createRoot(container); + }); + afterEach(() => { + act(() => root.unmount()); + activateSurface('local'); + usePromptSuggestionStore.setState({ suggestions: {}, enabledBySurface: {} }); + }); + + it('immediately hides and clears hints, rejects late events, and enables only new hints', async () => { + usePromptSuggestionStore.getState().setSuggestion('session', candidate); + await act(async () => root.render()); + expect(container.textContent).toBe('Run the tests'); + act(() => service.listeners.forEach(listener => listener({ enable_prompt_suggestion: false }))); + expect(container.textContent).toBe(''); + act(() => usePromptSuggestionStore.getState().setSuggestion('session', candidate)); + act(() => service.listeners.forEach(listener => listener({ enable_prompt_suggestion: true }))); + expect(container.textContent).toBe(''); + act(() => usePromptSuggestionStore.getState().setSuggestion('session', candidate)); + expect(container.textContent).toBe('Run the tests'); + }); + + it('does not show suggestions before settings load or for an older unsupported host', async () => { + service.cached.mockReturnValue(null); + let resolve!: (value: unknown) => void; + service.read.mockReturnValue(new Promise(done => { resolve = done; })); + usePromptSuggestionStore.getState().setSuggestion('session', candidate); + act(() => root.render()); + expect(container.textContent).toBe(''); + await act(async () => resolve({})); + expect(container.firstElementChild?.getAttribute('data-enabled')).toBe('false'); + expect(usePromptSuggestionStore.getState().suggestions).toEqual({}); + }); + + it('does not apply one host preference or late read to another host', async () => { + let resolveLocal!: (value: unknown) => void; + let resolvePeer!: (value: unknown) => void; + service.read.mockReturnValueOnce(new Promise(done => { resolveLocal = done; })) + .mockReturnValueOnce(new Promise(done => { resolvePeer = done; })); + usePromptSuggestionStore.getState().setSuggestion('session', candidate, 'local'); + usePromptSuggestionStore.getState().setSuggestion('session', candidate, 'peer'); + act(() => root.render()); + expect(container.textContent).toBe('Run the tests'); + act(() => activateSurface('peer')); + expect(container.textContent).toBe(''); + await act(async () => resolveLocal({ enable_prompt_suggestion: false })); + expect(usePromptSuggestionStore.getState().enabledBySurface.peer).toBeUndefined(); + await act(async () => resolvePeer({ enable_prompt_suggestion: false })); + expect(usePromptSuggestionStore.getState().suggestions[promptSuggestionKey('session', 'peer')]).toBeUndefined(); + expect(usePromptSuggestionStore.getState().suggestions[promptSuggestionKey('session', 'local')]).toEqual(candidate); + }); +}); diff --git a/src/web-ui/src/flow_chat/hooks/usePromptSuggestionsEnabled.ts b/src/web-ui/src/flow_chat/hooks/usePromptSuggestionsEnabled.ts new file mode 100644 index 0000000000..c3c22742d3 --- /dev/null +++ b/src/web-ui/src/flow_chat/hooks/usePromptSuggestionsEnabled.ts @@ -0,0 +1,19 @@ +import { useEffect, useSyncExternalStore } from 'react'; +import { useAIExperienceSettings } from '@/infrastructure/config/hooks/useAIExperienceSettings'; +import { getActiveSurfaceScope, onSurfaceActivated } from '@/infrastructure/peer-device/deviceSurface'; +import { usePromptSuggestionStore } from '../store/promptSuggestionStore'; + +/** Follow the execution host's setting and discard hints disabled by that host. */ +export function usePromptSuggestionsEnabled(): boolean { + const scope = useSyncExternalStore(onSurfaceActivated, getActiveSurfaceScope, getActiveSurfaceScope); + const { settings } = useAIExperienceSettings(); + const enabled = settings?.enable_prompt_suggestion === true; + + useEffect(() => { + if (settings && scope.isCurrent()) { + usePromptSuggestionStore.getState().setEnabled(enabled, scope.surfaceId); + } + }, [enabled, settings, scope]); + + return enabled; +} diff --git a/src/web-ui/src/flow_chat/store/promptSuggestionStore.test.ts b/src/web-ui/src/flow_chat/store/promptSuggestionStore.test.ts index 71df0856f9..aa267a1abb 100644 --- a/src/web-ui/src/flow_chat/store/promptSuggestionStore.test.ts +++ b/src/web-ui/src/flow_chat/store/promptSuggestionStore.test.ts @@ -9,17 +9,33 @@ import { } from './promptSuggestionStore'; const candidate = { suggestion: 'Run the tests', sourceTurnId: 't1' }; -const idle = { editorEmpty: true, value: '', canCompose: true, processing: false, latestTurnId: 't1', latestTurnStatus: 'completed' }; +const idle = { enabled: true, editorEmpty: true, value: '', canCompose: true, processing: false, latestTurnId: 't1', latestTurnStatus: 'completed' }; const key = (name: string, modifiers: Partial> = {}) => ({ key: name, shiftKey: false, ctrlKey: false, metaKey: false, altKey: false, ...modifiers, }); afterEach(() => { - usePromptSuggestionStore.setState({ suggestions: {} }); + usePromptSuggestionStore.setState({ suggestions: {}, enabledBySurface: {} }); activateSurface(LOCAL_SURFACE_ID); }); describe('prompt suggestion store', () => { + it('clears disabled host hints, rejects late results, and keeps other hosts independent', () => { + const store = usePromptSuggestionStore.getState(); + store.setSuggestion('a', candidate, LOCAL_SURFACE_ID); + store.setSuggestion('b', candidate, LOCAL_SURFACE_ID); + store.setSuggestion('a', candidate, 'peer'); + store.setEnabled(false, LOCAL_SURFACE_ID); + store.setSuggestion('a', candidate, LOCAL_SURFACE_ID); + expect(usePromptSuggestionStore.getState().suggestions).toEqual({ + [promptSuggestionKey('a', 'peer')]: candidate, + }); + store.setEnabled(true, LOCAL_SURFACE_ID); + expect(usePromptSuggestionStore.getState().suggestions[promptSuggestionKey('a')]).toBeUndefined(); + store.setSuggestion('a', candidate, LOCAL_SURFACE_ID); + expect(usePromptSuggestionStore.getState().suggestions[promptSuggestionKey('a')]).toEqual(candidate); + }); + it('keeps one suggestion per session and clears only the requested one', () => { const store = usePromptSuggestionStore.getState(); store.setSuggestion('a', { suggestion: 'Run the tests', sourceTurnId: 't1' }); @@ -52,6 +68,7 @@ describe('prompt suggestion store', () => { describe('visiblePromptSuggestion', () => { it('shows the suggestion only for an empty idle composer that can submit', () => { expect(visiblePromptSuggestion(candidate, idle)).toBe('Run the tests'); + expect(visiblePromptSuggestion(candidate, { ...idle, enabled: false })).toBeNull(); expect(visiblePromptSuggestion(undefined, idle)).toBeNull(); expect(visiblePromptSuggestion(candidate, { ...idle, value: 'R' })).toBeNull(); expect(visiblePromptSuggestion(candidate, { ...idle, editorEmpty: false })).toBeNull(); diff --git a/src/web-ui/src/flow_chat/store/promptSuggestionStore.ts b/src/web-ui/src/flow_chat/store/promptSuggestionStore.ts index c060021c0a..dfd5323330 100644 --- a/src/web-ui/src/flow_chat/store/promptSuggestionStore.ts +++ b/src/web-ui/src/flow_chat/store/promptSuggestionStore.ts @@ -9,6 +9,8 @@ export interface PromptSuggestion { interface PromptSuggestionState { suggestions: Record; + enabledBySurface: Record; + setEnabled: (enabled: boolean, surfaceId: DeviceSurfaceId) => void; setSuggestion: (sessionId: string, suggestion: PromptSuggestion, surfaceId?: DeviceSurfaceId) => void; clearSuggestion: (sessionId: string, surfaceId?: DeviceSurfaceId) => void; removeSurfaceSuggestions: (surfaceId: DeviceSurfaceId) => void; @@ -20,19 +22,31 @@ export function promptSuggestionKey(sessionId: string, surfaceId = getActiveSurf export const usePromptSuggestionStore = create((set) => ({ suggestions: {}, - setSuggestion: (sessionId, suggestion, surfaceId) => set((state) => ({ - suggestions: { ...state.suggestions, [promptSuggestionKey(sessionId, surfaceId)]: suggestion }, + enabledBySurface: {}, + setEnabled: (enabled, surfaceId) => set(state => ({ + enabledBySurface: { ...state.enabledBySurface, [surfaceId]: enabled }, + suggestions: enabled ? state.suggestions : Object.fromEntries(Object.entries(state.suggestions) + .filter(([key]) => JSON.parse(key)[0] !== surfaceId)), })), + setSuggestion: (sessionId, suggestion, surfaceId = getActiveSurfaceId()) => set((state) => ( + state.enabledBySurface[surfaceId] === false ? state : { + suggestions: { ...state.suggestions, [promptSuggestionKey(sessionId, surfaceId)]: suggestion }, + } + )), clearSuggestion: (sessionId, surfaceId) => set((state) => { const key = promptSuggestionKey(sessionId, surfaceId); if (!(key in state.suggestions)) return state; const { [key]: _removed, ...suggestions } = state.suggestions; return { suggestions }; }), - removeSurfaceSuggestions: (surfaceId) => set(state => ({ - suggestions: Object.fromEntries(Object.entries(state.suggestions) - .filter(([key]) => JSON.parse(key)[0] !== surfaceId)), - })), + removeSurfaceSuggestions: (surfaceId) => set(state => { + const { [surfaceId]: _removed, ...enabledBySurface } = state.enabledBySurface; + return { + enabledBySurface, + suggestions: Object.fromEntries(Object.entries(state.suggestions) + .filter(([key]) => JSON.parse(key)[0] !== surfaceId)), + }; + }), })); export function clearPromptSuggestion(sessionId: string | null | undefined): void { @@ -41,6 +55,7 @@ export function clearPromptSuggestion(sessionId: string | null | undefined): voi } export interface PromptSuggestionComposerState { + enabled: boolean; editorEmpty: boolean; value: string; canCompose: boolean; @@ -54,7 +69,7 @@ export function visiblePromptSuggestion( stored: PromptSuggestion | null | undefined, composer: PromptSuggestionComposerState, ): string | null { - if (!stored || !composer.editorEmpty || composer.value || !composer.canCompose || composer.processing) { + if (!composer.enabled || !stored || !composer.editorEmpty || composer.value || !composer.canCompose || composer.processing) { return null; } if (stored.sourceTurnId !== composer.latestTurnId || composer.latestTurnStatus !== 'completed') return null;