diff --git a/design-system/packages/ui/src/components/Dialog/Dialog.tsx b/design-system/packages/ui/src/components/Dialog/Dialog.tsx index a99e9a1652..3efe27b66e 100644 --- a/design-system/packages/ui/src/components/Dialog/Dialog.tsx +++ b/design-system/packages/ui/src/components/Dialog/Dialog.tsx @@ -153,7 +153,9 @@ const OverlaySurface = forwardRef(function ownerDocument, }); useFocusScope({ - active: open, + // A closed dialog mounts its surface on the presence commit. Start the + // focus scope only once that ref exists, including every subsequent open. + active: open && present, autoFocus, containerRef: surfaceRef, initialFocusRef, diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index aa024c3b22..d1de0d6502 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -117,6 +117,10 @@ For shared GitHub sign-in and token redaction, use For the matching cross-entry UI state, run `pnpm --dir src/web-ui run test:run src/infrastructure/account-identity/AccountIdentityService.test.ts src/features/market-account/AccountIdentityControls.test.tsx src/app/components/RemoteConnectDialog/ensureAccountSession.test.ts`. +For side-question request compatibility, initial model selection, and optional +message metadata, use +`cargo test -p openbitfun-desktop --no-default-features --lib api::btw_api::tests`. + For skill discovery response compatibility and timeouts, use `cargo test -p openbitfun-desktop --lib api::skill_api::tests`. For companion pet manifest versions and package metadata, use diff --git a/src/apps/desktop/src/api/btw_api.rs b/src/apps/desktop/src/api/btw_api.rs index 4f657717d1..675891ac7d 100644 --- a/src/apps/desktop/src/api/btw_api.rs +++ b/src/apps/desktop/src/api/btw_api.rs @@ -21,7 +21,7 @@ fn desktop_btw_submission_policy() -> DialogSubmissionPolicy { DialogSubmissionPolicy::for_source(DialogTriggerSource::DesktopUi) } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BtwAskStreamRequest { pub request_id: String, @@ -35,6 +35,11 @@ pub struct BtwAskStreamRequest { pub model_id: Option, #[serde(default)] pub image_contexts: Option>, + /// Optional presentation metadata. The question remains readable by older hosts. + #[serde(default)] + pub user_message_metadata: Option, + #[serde(default)] + pub initial_model_selection: Option, } #[derive(Debug, Clone, Serialize)] @@ -116,6 +121,8 @@ pub async fn btw_ask_stream( image_contexts, request.parent_dialog_turn_id.as_deref(), request.parent_turn_index, + request.user_message_metadata, + request.initial_model_selection, ) .await .map_err(|e| e.to_string())?; @@ -163,6 +170,49 @@ pub async fn btw_ask_stream( mod tests { use super::*; + #[test] + fn btw_request_reads_legacy_payloads_and_round_trips_optional_metadata() { + let legacy = serde_json::json!({ + "requestId": "request-1", + "sessionId": "parent-1", + "childSessionId": "child-1", + "question": "A readable quote and question" + }); + let request: BtwAskStreamRequest = serde_json::from_value(legacy.clone()).unwrap(); + assert!(request.user_message_metadata.is_none()); + assert!(request.initial_model_selection.is_none()); + let round_trip: BtwAskStreamRequest = + serde_json::from_value(serde_json::to_value(request).unwrap()).unwrap(); + assert_eq!(round_trip.question, legacy["question"]); + + let metadata = serde_json::json!({ + "composerPresentation": { "version": 1, "segments": [] }, + "sessionReferences": [{ "sessionId": "reference-1" }], + "permission_mode": "default" + }); + let mut current = legacy; + current["userMessageMetadata"] = metadata.clone(); + current["initialModelSelection"] = serde_json::json!({ + "modelId": "primary", "reasoningPreset": "high" + }); + current["futureField"] = serde_json::json!(true); + let request: BtwAskStreamRequest = serde_json::from_value(current).unwrap(); + let round_trip: BtwAskStreamRequest = + serde_json::from_value(serde_json::to_value(request).unwrap()).unwrap(); + assert_eq!(round_trip.user_message_metadata, Some(metadata)); + assert_eq!( + round_trip + .initial_model_selection + .unwrap() + .reasoning_preset + .as_deref(), + Some("high") + ); + let auto: openbitfun_runtime_ports::AgentSessionModelSelection = + serde_json::from_value(serde_json::json!({ "modelId": "primary" })).unwrap(); + assert!(auto.reasoning_preset.is_none()); + } + #[test] fn btw_turns_use_the_desktop_chat_output_surface() { assert_eq!( diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 15c14b1c1f..bb9d72b2dd 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -10877,6 +10877,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet image_contexts: Option>, parent_dialog_turn_id: Option<&str>, parent_turn_index: Option, + user_message_metadata: Option, + initial_model_selection: Option, ) -> OpenBitFunResult { if request_id.trim().is_empty() { return Err(OpenBitFunError::Validation( @@ -10910,7 +10912,14 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ) .await?; - if let Some(model_id) = model_id + if let Some(selection) = initial_model_selection { + self.update_session_model_selection( + child_session_id, + &selection.model_id, + selection.reasoning_preset.as_deref(), + ) + .await?; + } else if let Some(model_id) = model_id .map(str::trim) .filter(|model_id| !model_id.is_empty()) { @@ -10920,10 +10929,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } let turn_id = format!("btw-turn-{}", request_id.trim()); - let mut user_message_metadata = serde_json::json!({ - "kind": "btw", - "parentSessionId": parent_session_id, - }); + let mut user_message_metadata = user_message_metadata + .filter(serde_json::Value::is_object) + .unwrap_or_else(|| serde_json::json!({})); + user_message_metadata["kind"] = serde_json::json!("btw"); + user_message_metadata["parentSessionId"] = serde_json::json!(parent_session_id); if let Some(images) = image_contexts.as_ref().filter(|images| !images.is_empty()) { user_message_metadata["images"] = serde_json::Value::Array( images diff --git a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json index a43a6751e1..9df282226e 100644 --- a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json +++ b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "digest": "fnv1a64:2851d2dc0482c8ac", + "digest": "fnv1a64:9b623ef2dd041ad8", "retiredCommandPrefixes": [ { "prefix": "lsp_", @@ -11,6 +11,7 @@ "ids": [ "idempotent_dialog_submit", "inline_image_attachments_v1", + "btw_initial_model_selection_v1", "targeted_session_rollback", "token_usage_statistics", "miniapp_agent_context_files_v1", @@ -27,6 +28,7 @@ "desktop": [ "idempotent_dialog_submit", "inline_image_attachments_v1", + "btw_initial_model_selection_v1", "targeted_session_rollback", "token_usage_statistics", "miniapp_agent_context_files_v1", diff --git a/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs b/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs index 9329be14f5..cfae350582 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs @@ -27,6 +27,8 @@ pub enum PeerHostCapability { IdempotentDialogSubmit, /// Inline imageContexts are durably prepared by the receiving Runtime. InlineImageAttachmentsV1, + /// `btw_ask_stream` applies initial model and reasoning selection before the first turn. + BtwInitialModelSelectionV1, /// Identity-based `rollback_session_to_turn` is implemented. TargetedSessionRollback, /// `get_token_usage_statistics` is implemented. @@ -58,6 +60,7 @@ impl PeerHostCapability { pub const ALL: &'static [PeerHostCapability] = &[ Self::IdempotentDialogSubmit, Self::InlineImageAttachmentsV1, + Self::BtwInitialModelSelectionV1, Self::TargetedSessionRollback, Self::TokenUsageStatistics, Self::MiniappAgentContextFilesV1, @@ -77,6 +80,7 @@ impl PeerHostCapability { match self { Self::IdempotentDialogSubmit => "idempotent_dialog_submit", Self::InlineImageAttachmentsV1 => "inline_image_attachments_v1", + Self::BtwInitialModelSelectionV1 => "btw_initial_model_selection_v1", Self::TargetedSessionRollback => "targeted_session_rollback", Self::TokenUsageStatistics => "token_usage_statistics", Self::MiniappAgentContextFilesV1 => "miniapp_agent_context_files_v1", @@ -170,6 +174,14 @@ mod tests { advertised_by(PeerHostKind::Desktop).len(), PeerHostCapability::ALL.len() ); + assert!(advertises( + PeerHostKind::Desktop, + PeerHostCapability::BtwInitialModelSelectionV1 + )); + assert!(!advertises( + PeerHostKind::Cli, + PeerHostCapability::BtwInitialModelSelectionV1 + )); } #[test] diff --git a/src/web-ui/AGENTS.md b/src/web-ui/AGENTS.md index 4c793acfe3..8af6d30d9c 100644 --- a/src/web-ui/AGENTS.md +++ b/src/web-ui/AGENTS.md @@ -99,6 +99,13 @@ deliberate layout transitions or animate virtualized content. Rely on CI for full lint, build, and broad test coverage unless the local change specifically needs it. +For Appearance descriptor changes, also exercise production registration; its ID +and property validation goes beyond the static DOM audit: + +```bash +pnpm --dir src/web-ui run test:run src/infrastructure/appearance/registry/AppearanceRegistry.test.ts +``` + For Session selection, presentation synchronization, and scene lifetime changes, also run the focused state contracts: diff --git a/src/web-ui/src/flow_chat/components/ChatInput.appearance.ts b/src/web-ui/src/flow_chat/components/ChatInput.appearance.ts index 657175085b..428db454df 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.appearance.ts +++ b/src/web-ui/src/flow_chat/components/ChatInput.appearance.ts @@ -15,6 +15,8 @@ export const chatInputAppearanceDescriptor: AppearanceSurfaceDescriptor = { { id: 'image' }, { id: 'imagePreview' }, { id: 'imageRemove' }, + { id: 'attachment' }, + { id: 'attachmentRemove' }, { id: 'placeholder' }, { id: 'commandPicker' }, { id: 'commandHeader' }, diff --git a/src/web-ui/src/flow_chat/components/ChatInput.scss b/src/web-ui/src/flow_chat/components/ChatInput.scss index 958d8c58a6..a519aae21e 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.scss +++ b/src/web-ui/src/flow_chat/components/ChatInput.scss @@ -739,78 +739,6 @@ } } - &__image-strip { - display: flex; - flex-wrap: wrap; - gap: var(--openbitfun-control-flow-chat-inline-gap); - margin-bottom: var(--openbitfun-control-flow-chat-inline-gap); - padding: 0.12rem 0; - } - - &__image-chip { - position: relative; - width: 44px; - height: 44px; - border-radius: 6px; - overflow: visible; - background: var(--openbitfun-color-action-neutral-surface-hover); - border: 1px solid var(--openbitfun-color-border-subtle); - flex-shrink: 0; - - &:hover .openbitfun-chat-input__image-chip-remove { - opacity: 1; - transform: scale(1); - } - } - - &__image-chip-thumb { - width: 100%; - height: 100%; - object-fit: cover; - border-radius: 5px; - display: block; - - &--placeholder { - display: flex; - align-items: center; - justify-content: center; - color: var(--openbitfun-color-content-secondary); - } - } - - &__image-chip-remove { - position: absolute; - top: -6px; - right: -6px; - width: 18px; - height: 18px; - border-radius: 50%; - border: none; - background: var(--openbitfun-color-surface-raised); - color: var(--openbitfun-color-content-primary); - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - padding: 0; - box-shadow: 0 1px 3px color-mix(in srgb, var(--openbitfun-color-content-on-light) 35%, transparent); - opacity: 0; - transform: scale(0.85); - transition: - opacity 140ms ease, - transform 120ms cubic-bezier(0.23, 1, 0.32, 1); - - &:hover { - background: var(--openbitfun-color-action-neutral-surface-hover); - } - - &:focus-visible { - opacity: 1; - outline: 2px solid var(--openbitfun-color-accent-default); - outline-offset: 1px; - } - } - &__action-button { flex-shrink: 0; width: 20px; diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index f4dddf8026..411c44b395 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -1,4 +1,7 @@ -import { ChatInputImagePreview } from './ChatInputImagePreview'; +import { ChatInputAttachments } from './ChatInputAttachments'; +import { useExcerptComposerActions } from '../selection/useExcerptComposerActions'; +import { isConversationExcerpt, formatConversationExcerpt } from '@/shared/utils/conversationExcerpt'; +import { withConversationExcerpts } from '../utils/composerPresentation'; /** * Standalone chat input component * Separated from bottom bar, supports session-level state awareness @@ -220,6 +223,8 @@ import { import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; import type { SessionPermissionMode } from '@/infrastructure/api/service-api/AgentAPI'; import { isPeerDeviceModeActive } from '@/infrastructure/peer-device/peerModeFlag'; +import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; +import { isBtwSessionDraft } from '../utils/modelSelectionTarget'; import { workspaceAPI } from '@/infrastructure/api/service-api/WorkspaceAPI'; import { useLocalFileDrop } from '@/infrastructure/files/useLocalFileDrop'; import { useWindowsFileDropPreview } from '@/infrastructure/files/useWindowsFileDropPreview'; @@ -592,6 +597,7 @@ export const ChatInput: React.FC = ({ [contexts], ); const currentImageCount = imageContexts.length; + const hasAttachments = imageContexts.length > 0 || contexts.some(isConversationExcerpt); const activeSessionState = useActiveSessionState(); const activeBtwSessionTab = useAgentCanvasStore(state => selectActiveBtwSessionTab(state as any)); @@ -653,6 +659,10 @@ export const ChatInput: React.FC = ({ const effectiveTargetSession = effectiveTargetSessionId ? flowChatState.sessions.get(effectiveTargetSessionId) : undefined; + const peer = usePeerDeviceModeOptional(); + const isBtwDraftTarget = isBtwSessionDraft(effectiveTargetSession); + const btwDraftSettingsInherited = isBtwDraftTarget && Boolean(peer?.peerMode.active) + && peer?.currentPeerCapabilities?.btwInitialModelSelectionV1 !== true; const effectiveTargetSessionHasTurns = effectiveTargetSession ? !isProjectedSessionEmpty(effectiveTargetSession) : false; @@ -729,9 +739,16 @@ export const ChatInput: React.FC = ({ ); const derivedState = useSessionDerivedState( effectiveTargetSessionId, - inputState.value.trim() + inputState.value.trim() || (contexts.some(isConversationExcerpt) + ? t('selection.submitAnnotations') : '') ); const currentReviewActivity = useSessionReviewActivity(currentSessionId); + const annotationOnlyMessage = contexts.some(context => isConversationExcerpt(context) && context.comment?.trim()) + ? t('selection.submitAnnotations') : ''; + const hasSendableInput = Boolean(inputState.value.trim() || annotationOnlyMessage); + const focusExcerptComposer = useCallback(() => richTextInputRef.current?.focus(), []); + useExcerptComposerActions({ mainSessionId: currentSessionId, targetSessionId: effectiveTargetSessionId, + active: isSceneActive && !registration, setInputTarget, focus: focusExcerptComposer }); // The primary composer owns only the active primary session's requests. // Direct child-session requests are answered in BtwSessionPanel, even while // this composer is targeting that child, so the same request never has two @@ -850,8 +867,7 @@ export const ChatInput: React.FC = ({ return; } const hasNewline = inputState.value.includes('\n'); - const hasImages = imageContexts.length > 0; - if (hasNewline || hasImages || showTargetSwitcher) { + if (hasNewline || hasAttachments || showTargetSwitcher) { setIsMultiLine(true); return; } @@ -950,10 +966,10 @@ export const ChatInput: React.FC = ({ } lockedCapsuleInputWidthRef.current = nextLockedWidth; setIsMultiLine(nextIsMultiLine); - }, [inputState.value, imageContexts.length, isMultiLine, isNewSessionComposer, measureCapsuleInputWidth, showTargetSwitcher]); + }, [inputState.value, hasAttachments, isMultiLine, isNewSessionComposer, measureCapsuleInputWidth, showTargetSwitcher]); measureIsMultiLineRef.current = measureIsMultiLine; - // Re-measure when value or image count changes (handles typing / deleting) + // Re-measure when value or attachments change (handles typing / deleting) useEffect(() => { // Defer one frame so RichTextInput has synced the new value to the contenteditable DOM. const rafId = requestAnimationFrame(() => { @@ -1175,6 +1191,7 @@ export const ChatInput: React.FC = ({ sessionMode: effectiveTargetSession?.mode ?? effectiveTargetSession?.config.agentType, isAcpTargetSession, isSubagentInputTarget, + isBtwDraftTarget, }), [ effectiveTargetSession?.config.agentType, @@ -1182,6 +1199,7 @@ export const ChatInput: React.FC = ({ isAcpTargetSession, isAssistantWorkspace, isSubagentInputTarget, + isBtwDraftTarget, ], ); const globalPermissionMode = permissionModeFromConfig(toolPermissionConfig); @@ -2352,8 +2370,11 @@ export const ChatInput: React.FC = ({ } void (async () => { try { + const permissionSessionId = isBtwDraftTarget + ? effectiveTargetSession?.parentSessionId : effectiveTargetSessionId; + if (!permissionSessionId) return; const response = await agentAPI.getSessionPermissionMode({ - sessionId: effectiveTargetSessionId, + sessionId: permissionSessionId, turnId: activePermissionTurnId ?? undefined, workspacePath: effectiveTargetSession?.workspacePath, remoteConnectionId: effectiveTargetSession?.remoteConnectionId, @@ -2380,6 +2401,8 @@ export const ChatInput: React.FC = ({ effectiveTargetSession?.workspacePath, effectiveTargetSession?.remoteConnectionId, effectiveTargetSession?.remoteSshHost, + effectiveTargetSession?.parentSessionId, + isBtwDraftTarget, isAcpTargetSession, ]); @@ -3311,7 +3334,7 @@ export const ChatInput: React.FC = ({ // Image contexts are not represented by inline tag pills inside the // editor; they live in a separate thumbnail strip and are removed via // their own × button. Skip them when reconciling against editor tags. - if (context.type === 'image') return; + if (context.type === 'image' || context.type === 'conversation-excerpt') return; if (!activeContextIds.has(context.id)) { removeContext(context.id); } @@ -4882,9 +4905,10 @@ export const ChatInput: React.FC = ({ if (!derivedState) return; if (caps.transferInFlight) return; if (isInterruptedTurnRecoveryInFlight) return; + const submissionScope = getActiveSurfaceScope(); const { sendButtonMode } = derivedState; - const draftTrimmed = (messageOverride ?? inputState.value).trim(); + const draftTrimmed = (messageOverride ?? (inputState.value.trim() || annotationOnlyMessage)).trim(); // While generating, an empty control in `cancel` mode means stop. If the user has typed a follow-up, // never treat this path as cancel — that would call cancel_dialog_turn and abort the current round early. @@ -4901,14 +4925,16 @@ export const ChatInput: React.FC = ({ await transition(SessionExecutionEvent.RESET); } - if (!draftTrimmed) return; + if (!draftTrimmed) { + if (contexts.some(isConversationExcerpt)) notificationService.warning(t('selection.questionRequired')); + return; + } const originalMessage = draftTrimmed; const submissionSessionId = effectiveTargetSessionId; const submittedContexts = [...contexts]; - const composerPresentation = messageOverride === undefined - ? richTextInputRef.current?.getComposerPresentation?.() ?? null - : null; + const composerPresentation = withConversationExcerpts(messageOverride === undefined + ? richTextInputRef.current?.getComposerPresentation?.() ?? null : null, submittedContexts, draftTrimmed); const persistedComposerPresentation = hasComposerPresentationReferences(composerPresentation) ? composerPresentation : null; @@ -4919,9 +4945,10 @@ export const ChatInput: React.FC = ({ : originalMessage, ); const message = expandedMessage || (persistedComposerPresentation - ? 'Use the referenced session transcript as context.' + ? annotationOnlyMessage || 'Use the referenced session transcript as context.' : expandedMessage); - const messageCharCount = getCharacterCount(message); + const messageCharCount = getCharacterCount([message, + ...submittedContexts.filter(isConversationExcerpt).map(formatConversationExcerpt)].join('\n')); // Voice transcripts are always message content; they must not accidentally execute local commands. const promptSlashCommandsEnabled = !isAcpInputSession && @@ -4940,6 +4967,7 @@ export const ChatInput: React.FC = ({ )) { return; } + if (!submissionScope.isCurrent()) return; if (promptSlashCommandsEnabled && caps.ops.has('btw') && isSlashCommand(message, '/btw')) { // When idle, /btw can be sent via the normal send button. @@ -5031,7 +5059,7 @@ export const ChatInput: React.FC = ({ if (!modelAvailability.canSend) return; const confirmed = await confirmPromptCacheGuardIfNeeded(); - if (!confirmed) { + if (!confirmed || !submissionScope.isCurrent() || effectiveTargetSessionIdRef.current !== submissionSessionId) { return; } @@ -5053,6 +5081,9 @@ export const ChatInput: React.FC = ({ const clearedComposerRevision = submissionSessionId ? composerMutationRevision(submissionSessionId) : 0; + const clearedStoredDraft = submissionSessionId + ? sessionComposerStore.getState().getDraft(submissionSessionId) + : null; try { await submitThroughChatInputRegistration( @@ -5076,6 +5107,16 @@ export const ChatInput: React.FC = ({ }), ); } catch (error) { + if (!submissionScope.isCurrent()) { + // A failed old-host submission may recover only its own untouched draft. + const composer = sessionComposerStore.getState(); + if (submissionSessionId && composer.getDraft(submissionSessionId, submissionScope.surfaceId) === clearedStoredDraft) { + composer.setValue(submissionSessionId, originalMessage, submissionScope.surfaceId); + composer.setContexts(submissionSessionId, submittedContexts, submissionScope.surfaceId); + composer.setPendingLargePastes(submissionSessionId, originalPendingLargePastes, submissionScope.surfaceId); + } + return; + } log.error('Failed to send message', { error }); const recoveryTarget = failedSubmissionRecoveryTarget( submissionSessionId, @@ -5104,6 +5145,7 @@ export const ChatInput: React.FC = ({ caps.transferInFlight, isInterruptedTurnRecoveryInFlight, inputState.value, + annotationOnlyMessage, derivedState, dispatchInput, handleCancelCurrentTask, @@ -5543,7 +5585,7 @@ export const ChatInput: React.FC = ({ } if (derivedState?.isProcessing) { - if (!inputState.value.trim()) return; + if (!hasSendableInput) return; void handleSendOrCancel(); return; } @@ -5551,7 +5593,7 @@ export const ChatInput: React.FC = ({ handleSendOrCancel(); } - }, [canUseThreadGoal, handleSendOrCancel, submitBtwFromInput, submitGoalFromInput, derivedState, dispatchInput, slashCommandState, getActiveSlashPickerItems, selectSlashCommandAction, selectSlashExternalPromptCommand, selectSlashPromptCommand, selectSlashAcpCommand, selectSlashSkill, getRichTextTriggerController, historyIndex, inputHistory, savedDraft, inputState.value, currentSessionId, isBtwSession, showTargetSwitcher, setInputTarget, removeContext, t]); + }, [canUseThreadGoal, handleSendOrCancel, submitBtwFromInput, submitGoalFromInput, derivedState, dispatchInput, slashCommandState, getActiveSlashPickerItems, selectSlashCommandAction, selectSlashExternalPromptCommand, selectSlashPromptCommand, selectSlashAcpCommand, selectSlashSkill, getRichTextTriggerController, historyIndex, inputHistory, savedDraft, inputState.value, hasSendableInput, currentSessionId, isBtwSession, showTargetSwitcher, setInputTarget, removeContext, t]); const handleImeCompositionStart = useCallback(() => { isImeComposingRef.current = true; @@ -5806,13 +5848,13 @@ export const ChatInput: React.FC = ({ - + void handleSendOrCancel()} - disabled={!inputState.value.trim() || isModelSwitching || isModeChangePending || caps.transferInFlight || !modelAvailability.canSend} + disabled={!hasSendableInput || isModelSwitching || isModeChangePending || caps.transferInFlight || !modelAvailability.canSend} data-testid="chat-input-send-btn" icon={} variant="primary" @@ -5824,13 +5866,13 @@ export const ChatInput: React.FC = ({ } return ( - + void handleSendOrCancel()} - disabled={!inputState.value.trim() || isModelSwitching || isModeChangePending || caps.transferInFlight || !modelAvailability.canSend} + disabled={!hasSendableInput || isModelSwitching || isModeChangePending || caps.transferInFlight || !modelAvailability.canSend} data-testid="chat-input-send-btn" icon={} variant="primary" @@ -5869,6 +5911,7 @@ export const ChatInput: React.FC = ({ } : { mode: permissionMode, + disabled: isBtwDraftTarget, saving: permissionModeSaving, scopeLabel: t('chatInput.permissionMode.sessionScope'), overridden: permissionModeOverridden, @@ -6035,39 +6078,9 @@ export const ChatInput: React.FC = ({ )}
- {imageContexts.length > 0 && ( -
- {imageContexts.map(image => { - return ( -
- - -
- ); - })} -
- )} + useContextStore.getState().updateContext(id, { comment })} /> {showPlaceholder && ( {t('input.placeholder')} @@ -6628,8 +6641,9 @@ export const ChatInput: React.FC = ({ onAvailabilityChange={setModelAvailability} externalSelection={dispatchModelSelection} modeDefaultModelId={targetModeInfo?.model} - persistSharedModeDefault={Boolean(targetModeInfo && targetModeInfo.source !== 'external')} - disabled={isInterruptedTurnRecoveryInFlight} + persistSharedModeDefault={!isBtwDraftTarget && Boolean(targetModeInfo && targetModeInfo.source !== 'external')} + disabled={isInterruptedTurnRecoveryInFlight || btwDraftSettingsInherited} + disabledReason={btwDraftSettingsInherited ? t('selection.inheritedSettings') : undefined} reasoningTriggerPresentation="label" />
diff --git a/src/web-ui/src/flow_chat/components/ChatInputAttachment.scss b/src/web-ui/src/flow_chat/components/ChatInputAttachment.scss new file mode 100644 index 0000000000..a4dfa33ff5 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ChatInputAttachment.scss @@ -0,0 +1,69 @@ +.openbitfun-chat-input { + &__image-strip { + display: flex; + flex-wrap: wrap; + gap: var(--openbitfun-control-flow-chat-inline-gap); + margin-block-end: var(--openbitfun-control-flow-chat-inline-gap); + padding-block: var(--openbitfun-space-1); + } + + &__attachment { + position: relative; + inline-size: 44px; + block-size: 44px; + border-radius: var(--openbitfun-radius-sm); + background: var(--openbitfun-color-action-neutral-surface-hover); + border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); + flex-shrink: 0; + + &:hover .openbitfun-chat-input__attachment-remove, + &:focus-within .openbitfun-chat-input__attachment-remove { opacity: 1; } + } + + &__image-chip-thumb { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: inherit; + display: block; + + &--placeholder { + display: flex; + align-items: center; + justify-content: center; + color: var(--openbitfun-color-content-secondary); + } + } + + &__attachment-remove { + position: absolute; + inset-block-start: -6px; + inset-inline-end: -6px; + inline-size: 18px; + block-size: 18px; + border-radius: var(--openbitfun-radius-pill); + border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); + background: var(--openbitfun-color-surface-raised); + color: var(--openbitfun-color-content-secondary); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0; + opacity: 0; + + &:hover { + background: var(--openbitfun-color-action-neutral-surface-hover); + color: var(--openbitfun-color-content-primary); + } + + &:focus-visible { + outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); + outline-offset: var(--openbitfun-focus-offset); + } + } +} + +@media (hover: none) { + .openbitfun-chat-input__attachment-remove { opacity: 1; } +} diff --git a/src/web-ui/src/flow_chat/components/ChatInputAttachment.tsx b/src/web-ui/src/flow_chat/components/ChatInputAttachment.tsx new file mode 100644 index 0000000000..64e4479775 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ChatInputAttachment.tsx @@ -0,0 +1,31 @@ +import type { MouseEvent, ReactNode } from 'react'; +import { Icon } from '@openbitfun/ui'; +import './ChatInputAttachment.scss'; + +/** Images and annotations share the same composer attachment shell. */ +export function ChatInputAttachment({ children, label, removeLabel, onRemove, kind = 'image' }: { + children: ReactNode; + label: string; + removeLabel: string; + onRemove: () => void; + kind?: 'image' | 'annotation'; +}) { + const remove = (event: MouseEvent) => { event.stopPropagation(); onRemove(); }; + // Keep the existing image Appearance parts while sharing layout and behavior. + if (kind === 'image') return
+ {children} + +
; + return
+ {children} + +
; +} diff --git a/src/web-ui/src/flow_chat/components/ChatInputAttachments.test.tsx b/src/web-ui/src/flow_chat/components/ChatInputAttachments.test.tsx new file mode 100644 index 0000000000..d265f61855 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ChatInputAttachments.test.tsx @@ -0,0 +1,244 @@ +// @vitest-environment jsdom +import { act, useState } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { activateSurface, getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; +import { useContextStore } from '@/shared/stores/contextStore'; +import type { ContextItem, ConversationExcerptContext, ImageContext } from '@/shared/types/context'; +import { sessionComposerStore } from '../store/sessionComposerStore'; +import { ConversationExcerptPreview } from '../selection/ConversationExcerptAttachments'; +import { withConversationExcerpts } from '../utils/composerPresentation'; +import { UserMessagePresentationContent } from './modern/UserMessagePresentationContent'; +import { ChatInputAttachments } from './ChatInputAttachments'; + +const callbacks = vi.hoisted(() => ({ update: vi.fn(), remove: vi.fn(), outerKey: vi.fn(), locate: vi.fn(), warn: vi.fn() })); +vi.mock('../selection/locateConversationExcerpt', () => ({ locateConversationExcerpt: callbacks.locate })); +vi.mock('@/shared/notification-system', () => ({ notificationService: { warning: callbacks.warn } })); +vi.mock('../store/FlowChatStore', () => ({ flowChatStore: { + getState: () => ({ sessions: new Map([['main', { sessionId: 'main', dialogTurns: [] }]]) }), +} })); +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ + formatNumber: (number: number) => String(number), + t: (key: string, values?: Record) => key === 'selection.numbered' ? `Annotation ${values?.number}` + : key === 'selection.removeNumbered' ? `Remove ${values?.annotation}` : key, +}) })); + +const excerpt: ConversationExcerptContext = { + id: 'annotation-1', type: 'conversation-excerpt', timestamp: 1, annotationNumber: 1, + source: { surfaceId: 'local', sessionId: 'main', sessionName: 'Source session' }, + fragments: [{ turnId: 'turn', text: 'Original quoted source', start: 0, end: 22, prefix: '', suffix: '' }], + comment: 'First comment', +}; +const second = { ...excerpt, id: 'annotation-2', annotationNumber: 2, comment: 'Second comment' }; +const image: ImageContext = { + id: 'image', type: 'image', timestamp: 1, imageName: 'Photo.png', mimeType: 'image/png', + dataUrl: 'data:image/png;base64,AA==', +}; + +function Composer() { + const [contexts, setContexts] = useState([image, excerpt, second]); + return
+ { + callbacks.update(id, comment); + setContexts(current => current.map(item => item.id === id ? { ...item, comment } : item)); + }} + onRemove={id => { callbacks.remove(id); setContexts(current => current.filter(item => item.id !== id)); }} /> +
; +} + +function LinkedComposer() { + const contexts = useContextStore(state => state.contexts); + return <> + + useContextStore.getState().removeContext(id)} + onUpdate={(id, comment) => useContextStore.getState().updateContext(id, { comment })} /> + ; +} + +describe('numbered composer attachments', () => { + let root: Root; + let container: HTMLDivElement; + const frames = new Map(); + let frameId = 0; + const flushFrames = () => act(() => { + const pending = [...frames.values()]; frames.clear(); pending.forEach(callback => callback(0)); + }); + const dialog = () => document.querySelector('[role="dialog"][data-state="open"]'); + const click = (node: HTMLElement) => { act(() => node.click()); flushFrames(); }; + const trigger = (number: number) => container.querySelector(`button[aria-label="Annotation ${number}"]`)!; + const action = (key: string) => [...dialog()!.querySelectorAll('button')].find(button => button.textContent === key)!; + const typeComment = (value: string) => act(() => { + const textarea = dialog()!.querySelector('textarea')!; + Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call(textarea, value); + textarea.dispatchEvent(new Event('input', { bubbles: true })); + }); + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + activateSurface('local'); + vi.clearAllMocks(); + sessionComposerStore.setState({ drafts: {} }); + useContextStore.getState().clearContexts(); + frames.clear(); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => frames.delete(id)); + container = document.createElement('div'); document.body.append(container); + root = createRoot(container); + }); + afterEach(() => { + act(() => root.unmount()); container.remove(); + document.querySelectorAll('[data-openbitfun-overlay-host]').forEach(host => host.remove()); + vi.unstubAllGlobals(); + }); + + it('shows images and individual annotations in one strip and removes only the chosen attachment', () => { + act(() => root.render()); + const strip = container.querySelector('[data-openbitfun-part="imageStrip"]')!; + expect(strip.querySelector('img')?.alt).toBe('Photo.png'); + expect(strip.contains(trigger(1))).toBe(true); + expect(strip.contains(trigger(2))).toBe(true); + click(container.querySelector('[aria-label="Remove Annotation 1"]')!); + expect(callbacks.remove).toHaveBeenCalledWith('annotation-1'); + expect(trigger(1)).toBeNull(); + expect(trigger(2).textContent).toBe('#2'); + expect(strip.querySelector('img')).not.toBeNull(); + expect(dialog()).toBeNull(); + }); + + it('edits only the selected comment, keeps the number, and contains composer keyboard shortcuts', () => { + act(() => root.render()); + click(trigger(1)); + const textarea = dialog()!.querySelector('textarea')!; + expect(textarea.value).toBe('First comment'); + expect(document.activeElement).toBe(textarea); + const quote = dialog()!.querySelector('[data-openbitfun-product-part="quote"]')!; + expect(quote.textContent).toBe(`“${excerpt.fragments[0].text}”`); + const quoteText = quote.querySelector('[data-openbitfun-product-part="quoteText"]')!; + expect(quoteText.getAttribute('data-overflow-style')).toBe('ellipsis'); + expect(quoteText.getAttribute('data-overflow-behavior')).toBe('fade'); + expect(dialog()!.querySelector('label')).toBeNull(); + expect(textarea.getAttribute('placeholder')).toBeNull(); + expect(textarea.getAttribute('aria-label')).toBe('selection.annotation'); + expect(dialog()!.querySelector('blockquote')).toBeNull(); + act(() => { + textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call(textarea, ' Revised '); + textarea.dispatchEvent(new Event('input', { bubbles: true })); + }); + expect(callbacks.outerKey).not.toHaveBeenCalled(); + expect(callbacks.update).not.toHaveBeenCalled(); + act(() => textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true, bubbles: true }))); + expect(callbacks.update).toHaveBeenCalledExactlyOnceWith(excerpt.id, 'Revised'); + expect(dialog()).toBeNull(); + expect(trigger(1).textContent).toBe('#1'); + click(trigger(2)); + expect(dialog()!.querySelector('textarea')!.value).toBe('Second comment'); + click([...dialog()!.querySelectorAll('button')].find(button => button.textContent === 'selection.cancel')!); + expect(callbacks.update).toHaveBeenCalledOnce(); + }); + + it('views sent source markers and message annotations without editor controls or write shortcuts', () => { + act(() => root.render(
+ + +
)); + const triggers = container.querySelectorAll('[aria-label="Annotation 1"]'); + expect([...triggers].map(button => button.textContent)).toEqual(['1', 'Annotation 1']); + for (const trigger of triggers) { + click(trigger); + expect(dialog()!.querySelector('textarea, input, [contenteditable="true"]')).toBeNull(); + const comment = dialog()!.querySelector('[data-openbitfun-product-part="comment"]')!; + expect(comment.textContent).toBe('First comment'); + expect(dialog()!.querySelector('[data-openbitfun-product-part="quote"]')!.textContent).toBe(`“${excerpt.fragments[0].text}”`); + expect([...dialog()!.querySelectorAll('[data-openbitfun-part="footer"] button')].map(button => button.textContent)) + .toEqual(['selection.locate']); + act(() => comment.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true, bubbles: true }))); + expect(dialog()).not.toBeNull(); + click(action('selection.locate')); + expect(callbacks.locate).toHaveBeenLastCalledWith(excerpt, expect.any(Function)); + } + expect(callbacks.update).not.toHaveBeenCalled(); + expect(callbacks.outerKey).not.toHaveBeenCalled(); + expect(useContextStore.getState().contexts).toEqual([]); + }); + + it('keeps the sent snapshot read-only when the input contains an edited copy with the same ID', () => { + const draft = { ...excerpt, comment: 'Unsent draft revision' }; + useContextStore.getState().replaceContexts([draft]); + sessionComposerStore.getState().setContexts('main', [draft]); + act(() => root.render(<> + + + )); + click(trigger(1)); + expect(dialog()!.querySelector('[data-openbitfun-product-part="comment"]')!.textContent).toBe('First comment'); + expect(dialog()!.querySelector('textarea')).toBeNull(); + click(action('selection.locate')); + expect(callbacks.locate).toHaveBeenCalledExactlyOnceWith(excerpt, expect.any(Function)); + expect(useContextStore.getState().contexts).toEqual([draft]); + click(container.querySelector('[data-openbitfun-product-part="attachment"]')!); + expect(dialog()!.querySelector('textarea')!.value).toBe('Unsent draft revision'); + typeComment('Continue editing draft'); + click(action('selection.save')); + expect(callbacks.update).toHaveBeenCalledExactlyOnceWith(excerpt.id, 'Continue editing draft'); + }); + + it('shows only the source quote and locate action for a sent annotation without a comment', () => { + act(() => root.render()); + click(trigger(1)); + expect(dialog()!.querySelector('textarea, input, [data-openbitfun-product-part="comment"]')).toBeNull(); + expect(dialog()!.querySelector('[data-openbitfun-product-part="quote"]')!.textContent).toBe(`“${excerpt.fragments[0].text}”`); + expect(action('selection.locate')).toBeDefined(); + }); + + it('opens the same editor from source and attachment and reads the latest draft from either entry', () => { + useContextStore.getState().replaceContexts([excerpt]); + sessionComposerStore.getState().setContexts('main', [excerpt]); + act(() => root.render()); + const source = container.querySelector('[data-openbitfun-product-part="superscript"]')!; + const attachment = () => container.querySelector('[data-openbitfun-product-part="attachment"]')!; + click(source); + const actions = [...dialog()!.querySelectorAll('button')].map(button => button.textContent); + expect(dialog()!.dataset.openbitfunProductPart).toBe('dialog'); + typeComment('From the source'); + click(action('selection.save')); + expect(useContextStore.getState().contexts).toEqual([{ ...excerpt, comment: 'From the source' }]); + expect(sessionComposerStore.getState().getDraft('main').contexts).toEqual([{ ...excerpt, comment: 'From the source' }]); + click(attachment()); + expect(dialog()!.querySelector('textarea')!.value).toBe('From the source'); + expect([...dialog()!.querySelectorAll('button')].map(button => button.textContent)).toEqual(actions); + typeComment('From the attachment'); + click(action('selection.save')); + click(source); + expect(dialog()!.querySelector('textarea')!.value).toBe('From the attachment'); + typeComment('Cancelled change'); + click(action('selection.cancel')); + expect(useContextStore.getState().contexts).toEqual([{ ...excerpt, comment: 'From the attachment' }]); + }); + + it('locates from an attachment and saves dirty text once before locating', () => { + act(() => root.render()); + click(trigger(1)); + click(action('selection.locate')); + expect(callbacks.update).not.toHaveBeenCalled(); + expect(callbacks.locate).toHaveBeenCalledExactlyOnceWith(excerpt, expect.any(Function)); + click(trigger(1)); + typeComment(' Save before navigating '); + click(action('selection.saveAndLocate')); + expect(callbacks.update).toHaveBeenCalledExactlyOnceWith(excerpt.id, 'Save before navigating'); + expect(callbacks.locate).toHaveBeenLastCalledWith({ ...excerpt, comment: 'Save before navigating' }, expect.any(Function)); + expect(dialog()).toBeNull(); + }); + + it('keeps an unavailable editor open and rejects navigation after the surface activation changed', () => { + act(() => root.render()); + click(trigger(1)); + activateSurface('local'); + click(action('selection.locate')); + expect(callbacks.locate).not.toHaveBeenCalled(); + expect(callbacks.warn).toHaveBeenCalledWith('selection.editUnavailable'); + expect(dialog()).not.toBeNull(); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/ChatInputAttachments.tsx b/src/web-ui/src/flow_chat/components/ChatInputAttachments.tsx new file mode 100644 index 0000000000..6c8e1def6b --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ChatInputAttachments.tsx @@ -0,0 +1,27 @@ +import { useI18n } from '@/infrastructure/i18n'; +import type { ContextItem, ImageContext } from '@/shared/types/context'; +import { isConversationExcerpt } from '@/shared/utils/conversationExcerpt'; +import { ConversationExcerptAttachments } from '../selection/ConversationExcerptAttachments'; +import { ChatInputAttachment } from './ChatInputAttachment'; +import { ChatInputImagePreview } from './ChatInputImagePreview'; + +export function ChatInputAttachments({ contexts, surfaceEpoch, onRemove, onUpdate }: { + contexts: ContextItem[]; + surfaceEpoch: number; + onRemove: (id: string) => void; + onUpdate: (id: string, comment: string) => void; +}) { + const { t } = useI18n('flow-chat'); + const images = contexts.filter((context): context is ImageContext => context.type === 'image'); + if (!images.length && !contexts.some(isConversationExcerpt)) return null; + return
+ {images.map(image => ( + onRemove(image.id)}> + + + ))} + +
; +} diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.tsx b/src/web-ui/src/flow_chat/components/ModelSelector.tsx index 230ee3b149..ba66cb2ecf 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelector.tsx @@ -128,6 +128,8 @@ interface ModelSelectorProps { persistSharedModeDefault?: boolean; /** Whether lifecycle ownership currently prevents Session setting changes. */ disabled?: boolean; + /** Why the owning composer temporarily prevents setting changes. */ + disabledReason?: string; /** Compact trigger treatment supplied by the owning composer. */ reasoningTriggerPresentation?: 'meter' | 'label'; } @@ -331,6 +333,7 @@ export const ModelSelector: React.FC = ({ modeDefaultModelId, persistSharedModeDefault = true, disabled = false, + disabledReason, reasoningTriggerPresentation = 'meter', }) => { const { t } = useTranslation('flow-chat'); @@ -1847,7 +1850,7 @@ export const ModelSelector: React.FC = ({ data-openbitfun-state={[displayedAvailability.status, dropdownOpen && 'open'].filter(Boolean).join(' ')} > {showModelTrigger && ( - + + + {dialog && setDialog(current => current && { ...current, open })} />} + ; +} + +export function ConversationExcerptAttachments({ contexts, onUpdate, onRemove, inline = false }: { + contexts: ContextItem[]; + onUpdate: (id: string, comment: string) => void; + onRemove: (id: string) => void; + inline?: boolean; +}) { + const excerpts = contexts.filter(isConversationExcerpt); + if (!excerpts.length) return null; + return
+ {excerpts.map(excerpt => )} +
; +} + +export function ConversationExcerptPreview({ excerpt, superscript = false, origin = 'sent' }: { + excerpt: ConversationExcerptContext; + superscript?: boolean; + origin?: 'source' | 'sent'; +}) { + const { label, numberLabel } = useExcerptLabel(excerpt); + const [dialog, setDialog] = useState(null); + const openDialog = () => setDialog({ open: true, target: conversationExcerptDialogTarget(excerpt, origin) }); + return event.stopPropagation()}> + {superscript ? + : } + {dialog && setDialog(current => current && { ...current, open })} />} + ; +} diff --git a/src/web-ui/src/flow_chat/selection/ConversationExcerptDialog.tsx b/src/web-ui/src/flow_chat/selection/ConversationExcerptDialog.tsx new file mode 100644 index 0000000000..ca5dd0eea7 --- /dev/null +++ b/src/web-ui/src/flow_chat/selection/ConversationExcerptDialog.tsx @@ -0,0 +1,76 @@ +import { useLayoutEffect, useRef, useState } from 'react'; +import { + Button, Dialog, DialogBody, DialogClose, DialogFooter, DialogHeader, DialogHeading, + DialogTitle, ScrollArea, +} from '@openbitfun/ui'; +import { useI18n } from '@/infrastructure/i18n'; +import { notificationService } from '@/shared/notification-system'; +import type { ExcerptDialogTarget } from './conversationExcerptEditing'; +import { ConversationExcerptEditor, ConversationExcerptQuote } from './ConversationExcerptEditor'; +import { locateConversationExcerpt } from './locateConversationExcerpt'; +import './ConversationExcerpt.scss'; + +/** Shared dialog anatomy with explicit viewing and pending-edit modes. */ +export function ConversationExcerptDialog({ target, label, open, onOpenChange }: { + target: ExcerptDialogTarget; + label: string; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { t } = useI18n('flow-chat'); + const inputRef = useRef(null); + const [comment, setComment] = useState(target.excerpt.comment ?? ''); + useLayoutEffect(() => { if (open) setComment(target.excerpt.comment ?? ''); }, [open, target]); + const dirty = target.mode === 'edit' && comment.trim() !== (target.excerpt.comment ?? '').trim(); + const available = target.isCurrent(); + const commit = () => { + if (target.mode !== 'edit') return false; + const result = target.save(comment); + if (result === 'saved') return true; + notificationService.warning(t(result === 'queue-unavailable' ? 'selection.queueEditUnavailable' : 'selection.editUnavailable')); + return false; + }; + const save = () => { if (commit()) onOpenChange(false); }; + const locate = () => { + if (!target.isCurrent()) { + notificationService.warning(t(target.mode === 'edit' ? 'selection.editUnavailable' : 'selection.sourceUnavailable')); + return; + } + // Navigation closes the editor; commit changed text explicitly so it is not lost. + if (dirty && !commit()) return; + onOpenChange(false); + void locateConversationExcerpt(target.mode === 'edit' ? { ...target.excerpt, comment: comment.trim() } : target.excerpt, + () => notificationService.warning(t('selection.sourceUnavailable'))); + }; + const locateLabel = dirty ? t('selection.saveAndLocate') : t('selection.locate'); + return event.stopPropagation()}> + + {label} + + + +
+ {target.mode === 'edit' ? : <> + + {target.excerpt.comment?.trim() && + {target.excerpt.comment} + } + } +
+
+ + + {target.mode === 'edit' && <> + + + } + +
; +} diff --git a/src/web-ui/src/flow_chat/selection/ConversationExcerptEditor.tsx b/src/web-ui/src/flow_chat/selection/ConversationExcerptEditor.tsx new file mode 100644 index 0000000000..945b4d36d7 --- /dev/null +++ b/src/web-ui/src/flow_chat/selection/ConversationExcerptEditor.tsx @@ -0,0 +1,38 @@ +import type { RefObject } from 'react'; +import { OverflowText, Textarea } from '@openbitfun/ui'; +import { useI18n } from '@/infrastructure/i18n'; +import type { ConversationExcerptContext } from '@/shared/types/context'; +import { excerptText } from '@/shared/utils/conversationExcerpt'; + +export function ConversationExcerptQuote({ excerpt }: { excerpt: ConversationExcerptContext }) { + const quote = excerptText(excerpt).replace(/\s+/g, ' ').trim(); + return
+ “ + {quote} + ” +
; +} + +/** Shared compact content for creating and editing an annotation. */ +export function ConversationExcerptEditor({ excerpt, comment, onCommentChange, inputRef, onSubmit }: { + excerpt: ConversationExcerptContext; + comment: string; + onCommentChange: (comment: string) => void; + inputRef: RefObject; + onSubmit: () => void; +}) { + const { t } = useI18n('flow-chat'); + return <> + +