diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index f8f995d22f..035825a611 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -4352,11 +4352,11 @@ pub(crate) fn reveal_local_path_in_explorer( .spawn() .map_err(|e| format!("Failed to open explorer: {}", e))?; } else { - let normalized_path = path_str.replace("/", "\\"); - openbitfun_core::util::process_manager::create_command("explorer") - .arg(format!("/select,{}", normalized_path)) - .spawn() - .map_err(|e| format!("Failed to open explorer: {}", e))?; + // Explorer does not use standard argv quoting for /select: Command + // quotes the entire switch + path when a filename contains spaces. + // Use Shell item IDs instead so the path is never a command line. + tauri_plugin_opener::reveal_item_in_dir(path) + .map_err(|e| format!("Failed to reveal file in explorer: {}", e))?; } } diff --git a/src/web-ui/src/flow_chat/components/modern/ExportImageButton.scss b/src/web-ui/src/flow_chat/components/modern/ExportImageButton.scss index a58443971f..d1c0f9e711 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExportImageButton.scss +++ b/src/web-ui/src/flow_chat/components/modern/ExportImageButton.scss @@ -5,6 +5,11 @@ overflow: visible; } +.export-image-menu { + position: fixed; + z-index: var(--openbitfun-layer-popover); +} + // Export content container .export-content { /* Width is set dynamically in JS to match the live chat pane width. @@ -147,7 +152,9 @@ &__thinking-item { margin-bottom: 8px; - + } + + &__thinking-item--expanded { // Show full thinking content without scrollbars. .thinking-content { max-height: none !important; @@ -206,4 +213,3 @@ white-space: nowrap; } } - diff --git a/src/web-ui/src/flow_chat/components/modern/ExportImageButton.tsx b/src/web-ui/src/flow_chat/components/modern/ExportImageButton.tsx index e0cc7fd009..a27a025f2b 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExportImageButton.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ExportImageButton.tsx @@ -4,15 +4,18 @@ * Uses modern-screenshot (fork of html-to-image with better CSS var / font / CORS handling). */ -import React, { useState, useCallback, useRef } from 'react'; +import React, { useState, useCallback, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; import { createRoot } from 'react-dom/client'; import { FlowChatStore } from '../../store/FlowChatStore'; import { notificationService } from '@/shared/notification-system'; import { FlowTextBlock } from '../FlowTextBlock'; import { FlowToolCard } from '../FlowToolCard'; -import { Icon, Tooltip } from '@openbitfun/ui'; +import { Icon, Menu, MenuItem, Tooltip } from '@openbitfun/ui'; +import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; +import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; import type { DialogTurn, FlowTextItem, FlowToolItem, FlowThinkingItem } from '../../types/flow-chat'; -import { i18nService } from '@/infrastructure/i18n'; +import { i18nService, useI18n } from '@/infrastructure/i18n'; import { workspaceAPI } from '@/infrastructure/api'; import { createLogger } from '@/shared/utils/logger'; import { getBuiltinAppearanceThemeToken } from '@/infrastructure/appearance/builtins/catalog'; @@ -74,12 +77,13 @@ interface ExportImageButtonProps { // Exported content renderer. interface ExportContentProps { dialogTurn: DialogTurn; + expandThinking: boolean; } // Marker class on the logo placeholder so we can locate it for canvas compositing. const LOGO_PLACEHOLDER_CLASS = 'export-content__logo-placeholder'; -const ExportContent: React.FC = ({ dialogTurn }) => { +const ExportContent: React.FC = ({ dialogTurn, expandThinking }) => { return (
@@ -133,13 +137,14 @@ const ExportContent: React.FC = ({ dialogTurn }) => { } else if (item.type === 'thinking') { const thinkingItem = item as FlowThinkingItem; return ( -
+
); @@ -171,7 +176,38 @@ export const ExportImageButton: React.FC = ({ turnId, className = '' }) => { + const { t } = useI18n('flow-chat'); const [isExporting, setIsExporting] = useState(false); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const buttonRef = useRef(null); + const menuRef = useRef(null); + const menuLayout = useAnchoredPopoverPosition({ + open: isMenuOpen, + anchorRef: buttonRef, + popoverRef: menuRef, + preferredPlacement: 'top', + alignment: 'end', + gap: 4, + }); + + useEffect(() => { + if (!isMenuOpen) return; + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + if (!buttonRef.current?.contains(target) && !menuRef.current?.contains(target)) { + setIsMenuOpen(false); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setIsMenuOpen(false); + }; + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isMenuOpen]); // Ref guard to prevent double-invocation while state update is pending. const isExportingRef = useRef(false); @@ -187,8 +223,9 @@ export const ExportImageButton: React.FC = ({ return null; }, [turnId]); - const handleExport = useCallback(async () => { + const handleExport = useCallback(async (expandThinking: boolean) => { if (isExportingRef.current) return; + setIsMenuOpen(false); isExportingRef.current = true; setIsExporting(true); @@ -283,7 +320,7 @@ export const ExportImageButton: React.FC = ({ // Render export content with React. root = createRoot(wrapper); - root.render(); + root.render(); // Adaptive render wait: base time + per-round allowance, capped. const roundCount = dialogTurn.modelRounds?.length ?? 1; @@ -545,23 +582,48 @@ export const ExportImageButton: React.FC = ({ }, [getDialogTurn]); return ( - - - + <> + + + + {isMenuOpen && createPortal( + + void handleExport(false)}> + {t('exportImage.collapsedThinking')} + + void handleExport(true)}> + {t('exportImage.expandedThinking')} + + , + getAppearanceOverlayHost(), + )} + ); }; diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 19caa62686..6200245a52 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -1494,6 +1494,8 @@ } }, "exportImage": { + "collapsedThinking": "Compact image", + "expandedThinking": "Image with thinking", "subtitle": "AI Programming Assistant · Chat Record", "poweredBy": "Powered by", "aiAssistant": "AI Programming Assistant", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index ebbb9d1754..a105baeade 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -1494,6 +1494,8 @@ } }, "exportImage": { + "collapsedThinking": "简洁长图", + "expandedThinking": "含思考长图", "subtitle": "AI 编程助手 · 对话记录", "poweredBy": "Powered by", "aiAssistant": "AI 编程助手", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index b5610742d2..3135d804a8 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -1494,6 +1494,8 @@ } }, "exportImage": { + "collapsedThinking": "精簡長圖", + "expandedThinking": "含思考長圖", "subtitle": "AI 編程助手 · 對話記錄", "poweredBy": "Powered by", "aiAssistant": "AI 編程助手",