From 0029a8ceb3136999e4f71cfb0ae25ed59d385e72 Mon Sep 17 00:00:00 2001 From: tuandui Date: Tue, 10 Mar 2026 02:06:14 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=98=BE=E7=A4=BA=E6=A8=A1=E5=BC=8F=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持左对齐和左右分布两种消息布局模式 - Chat 模式和 Agent 模式均支持该功能 - 在通用设置中添加卡片式布局选择器,带预览图 - 用户消息气泡自适应内容宽度 - 设置持久化保存到 ~/.proma/settings.json --- apps/electron/src/main/index.ts | 11 +- .../electron/src/main/lib/settings-service.ts | 5 +- .../src/renderer/atoms/chat-message-layout.ts | 69 +++++++++ .../components/agent/AgentMessages.tsx | 17 ++- .../components/ai-elements/message.tsx | 7 +- .../components/chat/ChatMessageItem.tsx | 25 ++-- .../renderer/components/chat/ChatMessages.tsx | 7 +- .../components/settings/GeneralSettings.tsx | 135 ++++++++++++++++++ apps/electron/src/renderer/main.tsx | 20 +++ apps/electron/src/types/settings.ts | 8 ++ bun.lock | 1 + 11 files changed, 275 insertions(+), 30 deletions(-) create mode 100644 apps/electron/src/renderer/atoms/chat-message-layout.ts diff --git a/apps/electron/src/main/index.ts b/apps/electron/src/main/index.ts index a3cc6524..9afc8afe 100644 --- a/apps/electron/src/main/index.ts +++ b/apps/electron/src/main/index.ts @@ -112,13 +112,8 @@ function createWindow(): void { }) // Load the renderer - const isDev = !app.isPackaged - if (isDev) { - mainWindow.loadURL('http://localhost:5173') - mainWindow.webContents.openDevTools() - } else { - mainWindow.loadFile(join(__dirname, 'renderer', 'index.html')) - } + // 强制使用本地构建文件,不依赖开发服务器 + mainWindow.loadFile(join(__dirname, 'renderer', 'index.html')) // 窗口就绪后最大化显示 mainWindow.once('ready-to-show', () => { @@ -128,8 +123,6 @@ function createWindow(): void { // 拦截页面内导航,外部链接用系统浏览器打开,防止 Electron 窗口被覆盖 mainWindow.webContents.on('will-navigate', (event, url) => { - // 允许开发模式下的 Vite HMR 热重载 - if (isDev && url.startsWith('http://localhost:')) return event.preventDefault() if (url.startsWith('http://') || url.startsWith('https://')) { shell.openExternal(url) diff --git a/apps/electron/src/main/lib/settings-service.ts b/apps/electron/src/main/lib/settings-service.ts index 9dbc7eb3..184b63a2 100644 --- a/apps/electron/src/main/lib/settings-service.ts +++ b/apps/electron/src/main/lib/settings-service.ts @@ -7,7 +7,7 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs' import { getSettingsPath } from './config-paths' -import { DEFAULT_THEME_MODE } from '../../types' +import { DEFAULT_THEME_MODE, DEFAULT_CHAT_MESSAGE_LAYOUT } from '../../types' import type { AppSettings } from '../../types' /** @@ -21,6 +21,7 @@ export function getSettings(): AppSettings { if (!existsSync(filePath)) { return { themeMode: DEFAULT_THEME_MODE, + chatMessageLayout: DEFAULT_CHAT_MESSAGE_LAYOUT, onboardingCompleted: false, environmentCheckSkipped: false, notificationsEnabled: true, @@ -33,6 +34,7 @@ export function getSettings(): AppSettings { return { ...data, themeMode: data.themeMode || DEFAULT_THEME_MODE, + chatMessageLayout: data.chatMessageLayout || DEFAULT_CHAT_MESSAGE_LAYOUT, onboardingCompleted: data.onboardingCompleted ?? false, environmentCheckSkipped: data.environmentCheckSkipped ?? false, notificationsEnabled: data.notificationsEnabled ?? true, @@ -41,6 +43,7 @@ export function getSettings(): AppSettings { console.error('[设置] 读取失败:', error) return { themeMode: DEFAULT_THEME_MODE, + chatMessageLayout: DEFAULT_CHAT_MESSAGE_LAYOUT, onboardingCompleted: false, environmentCheckSkipped: false, notificationsEnabled: true, diff --git a/apps/electron/src/renderer/atoms/chat-message-layout.ts b/apps/electron/src/renderer/atoms/chat-message-layout.ts new file mode 100644 index 00000000..d537a576 --- /dev/null +++ b/apps/electron/src/renderer/atoms/chat-message-layout.ts @@ -0,0 +1,69 @@ +/** + * 消息布局模式状态原子 + * + * 管理会话消息显示模式: + * - left-aligned: 消息气泡左对齐(当前应用中的形式) + * - left-right: 消息气泡左右分布(类似微信,自己发在右边,AI发在左边) + * + * 使用 localStorage 作为缓存,避免页面加载时闪烁。 + */ + +import { atom } from 'jotai' +import type { ChatMessageLayout } from '../../types' + +/** localStorage 缓存键 */ +const LAYOUT_CACHE_KEY = 'proma-chat-message-layout' + +/** + * 从 localStorage 读取缓存的消息布局模式 + */ +function getCachedLayout(): ChatMessageLayout { + try { + const cached = localStorage.getItem(LAYOUT_CACHE_KEY) + if (cached === 'left-aligned' || cached === 'left-right') { + return cached + } + } catch { + // localStorage 不可用时忽略 + } + return 'left-aligned' +} + +/** + * 缓存消息布局模式到 localStorage + */ +function cacheLayout(layout: ChatMessageLayout): void { + try { + localStorage.setItem(LAYOUT_CACHE_KEY, layout) + } catch { + // localStorage 不可用时忽略 + } +} + +/** 消息布局模式原子 */ +export const chatMessageLayoutAtom = atom(getCachedLayout()) + +/** + * 初始化消息布局设置 + * + * 从主进程加载持久化设置。 + */ +export async function initializeChatMessageLayout( + setLayout: (layout: ChatMessageLayout) => void, +): Promise { + // 从主进程加载持久化设置 + const settings = await window.electronAPI.getSettings() + const layout = settings.chatMessageLayout ?? 'left-aligned' + setLayout(layout) + cacheLayout(layout) +} + +/** + * 更新消息布局模式并持久化 + * + * 同时更新 localStorage 缓存和主进程配置文件。 + */ +export async function updateChatMessageLayout(layout: ChatMessageLayout): Promise { + cacheLayout(layout) + await window.electronAPI.updateSettings({ chatMessageLayout: layout }) +} diff --git a/apps/electron/src/renderer/components/agent/AgentMessages.tsx b/apps/electron/src/renderer/components/agent/AgentMessages.tsx index 8eb33c6e..d053ba5b 100644 --- a/apps/electron/src/renderer/components/agent/AgentMessages.tsx +++ b/apps/electron/src/renderer/components/agent/AgentMessages.tsx @@ -35,6 +35,7 @@ import { ToolActivityList } from './ToolActivityItem' import { BackgroundTasksPanel } from './BackgroundTasksPanel' import { useBackgroundTasks } from '@/hooks/useBackgroundTasks' import { userProfileAtom } from '@/atoms/user-profile' +import { chatMessageLayoutAtom } from '@/atoms/chat-message-layout' import { cn } from '@/lib/utils' import type { AgentMessage, RetryAttempt } from '@proma/shared' import type { ToolActivity, AgentStreamState } from '@/atoms/agent-atoms' @@ -383,34 +384,36 @@ interface AgentMessageItemProps { function AgentMessageItem({ message, onRetry, onRetryInNewSession }: AgentMessageItemProps): React.ReactElement | null { const userProfile = useAtomValue(userProfileAtom) + const chatMessageLayout = useAtomValue(chatMessageLayoutAtom) + const isLeftRightLayout = chatMessageLayout === 'left-right' if (message.role === 'user') { const { files: attachedFiles, text: messageText } = parseAttachedFiles(message.content) return ( - -
+ +
-
+
{userProfile.userName} {formatMessageTime(message.createdAt)}
- + {attachedFiles.length > 0 && ( -
+
{attachedFiles.map((file) => ( ))}
)} {messageText && ( - {messageText} + {messageText} )} {/* 操作按钮(hover 时可见) */} {messageText && ( - + )} diff --git a/apps/electron/src/renderer/components/ai-elements/message.tsx b/apps/electron/src/renderer/components/ai-elements/message.tsx index ba135835..34d79253 100644 --- a/apps/electron/src/renderer/components/ai-elements/message.tsx +++ b/apps/electron/src/renderer/components/ai-elements/message.tsx @@ -120,8 +120,7 @@ export function MessageContent({
!prev) }, []) + const isRightAligned = className?.includes('text-right') + return ( -
+
=> { @@ -125,12 +130,16 @@ export function ChatMessageItem({ void onSubmitInlineEdit(message, payload) }, [message, onSubmitInlineEdit]) - // 并排模式下,user 消息不使用 from="user" 以避免右对齐 + // 并排模式下,user 消息强制左对齐(忽略左右分布设置) + // 普通模式下,根据布局设置决定:左对齐或左右分布 const messageFrom = isParallelMode ? 'assistant' : message.role return ( <> - + {/* assistant 头像 + 模型名 + 时间 */} {message.role === 'assistant' && ( +
-
+
{userProfile.userName} {formatMessageTime(message.createdAt)}
)} - + {message.role === 'assistant' ? ( <> {/* 工具活动记录(历史消息) */} @@ -193,7 +202,7 @@ export function ChatMessageItem({ /* 用户消息 - 附件 + 可折叠文本 / 原地编辑 */ <> {!isInlineEditing && message.attachments && message.attachments.length > 0 && ( - + )} {isInlineEditing ? ( onCancelInlineEdit?.()} /> ) : message.content && ( - {message.content} + {message.content} )} )} @@ -210,7 +219,7 @@ export function ChatMessageItem({ {/* 操作按钮(非 streaming 时显示,hover 时可见) */} {(message.content || (message.attachments && message.attachments.length > 0)) && !isStreaming && !isInlineEditing && ( - + {message.role === 'user' && onResendMessage && ( + } /> - + {/* 工具活动指示器 */} diff --git a/apps/electron/src/renderer/components/settings/GeneralSettings.tsx b/apps/electron/src/renderer/components/settings/GeneralSettings.tsx index 71f0510e..4bdaddfb 100644 --- a/apps/electron/src/renderer/components/settings/GeneralSettings.tsx +++ b/apps/electron/src/renderer/components/settings/GeneralSettings.tsx @@ -16,6 +16,8 @@ import { SettingsRow, SettingsToggle, } from './primitives' +import { Check } from 'lucide-react' +import { LABEL_CLASS, DESCRIPTION_CLASS } from './primitives/SettingsUIConstants' import { Popover, PopoverTrigger, PopoverContent } from '../ui/popover' import { UserAvatar } from '../chat/UserAvatar' import { userProfileAtom } from '@/atoms/user-profile' @@ -23,7 +25,12 @@ import { notificationsEnabledAtom, updateNotificationsEnabled, } from '@/atoms/notifications' +import { + chatMessageLayoutAtom, + updateChatMessageLayout, +} from '@/atoms/chat-message-layout' import { cn } from '@/lib/utils' +import type { ChatMessageLayout } from '../../../types' /** emoji-mart 选择回调的 emoji 对象类型 */ interface EmojiMartEmoji { @@ -35,9 +42,24 @@ interface EmojiMartEmoji { shortcodes: string } +/** 布局模式定义 */ +const LAYOUT_MODES = [ + { + value: 'left-aligned' as const, + label: '左对齐', + description: '所有消息左对齐显示', + }, + { + value: 'left-right' as const, + label: '左右分布', + description: '用户消息右对齐,AI 消息左对齐', + }, +] + export function GeneralSettings(): React.ReactElement { const [userProfile, setUserProfile] = useAtom(userProfileAtom) const [notificationsEnabled, setNotificationsEnabled] = useAtom(notificationsEnabledAtom) + const [chatMessageLayout, setChatMessageLayout] = useAtom(chatMessageLayoutAtom) const [isEditingName, setIsEditingName] = React.useState(false) const [nameInput, setNameInput] = React.useState(userProfile.userName) const [showEmojiPicker, setShowEmojiPicker] = React.useState(false) @@ -92,6 +114,12 @@ export function GeneralSettings(): React.ReactElement { } } + /** 切换消息布局模式 */ + const handleLayoutChange = React.useCallback((layout: ChatMessageLayout) => { + setChatMessageLayout(layout) + updateChatMessageLayout(layout) + }, [setChatMessageLayout]) + return (
{/* 用户档案区域 */} @@ -211,6 +239,113 @@ export function GeneralSettings(): React.ReactElement { updateNotificationsEnabled(checked) }} /> + {/* 会话显示模式 - 卡片式选择器 */} +
+
会话显示模式
+
选择消息气泡的排列方式
+
+ {LAYOUT_MODES.map((mode) => ( + + ))} +
+
diff --git a/apps/electron/src/renderer/main.tsx b/apps/electron/src/renderer/main.tsx index 48c9ca83..b64d6b32 100644 --- a/apps/electron/src/renderer/main.tsx +++ b/apps/electron/src/renderer/main.tsx @@ -34,6 +34,10 @@ import { notificationsEnabledAtom, initializeNotifications, } from './atoms/notifications' +import { + chatMessageLayoutAtom, + initializeChatMessageLayout, +} from './atoms/chat-message-layout' import { useGlobalAgentListeners } from './hooks/useGlobalAgentListeners' import { useGlobalChatListeners } from './hooks/useGlobalChatListeners' import { tabsAtom, splitLayoutAtom } from './atoms/tab-atoms' @@ -240,6 +244,21 @@ function NotificationsInitializer(): null { return null } +/** + * 消息布局初始化组件 + * + * 从主进程加载消息布局设置。 + */ +function ChatMessageLayoutInitializer(): null { + const setLayout = useSetAtom(chatMessageLayoutAtom) + + useEffect(() => { + initializeChatMessageLayout(setLayout) + }, [setLayout]) + + return null +} + /** * Chat IPC 监听器初始化组件 * @@ -357,6 +376,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render( + diff --git a/apps/electron/src/types/settings.ts b/apps/electron/src/types/settings.ts index 6228d561..f7649ea7 100644 --- a/apps/electron/src/types/settings.ts +++ b/apps/electron/src/types/settings.ts @@ -9,9 +9,15 @@ import type { EnvironmentCheckResult, PromaPermissionMode, ThinkingConfig, Agent /** 主题模式 */ export type ThemeMode = 'light' | 'dark' | 'system' +/** 消息布局模式 */ +export type ChatMessageLayout = 'left-aligned' | 'left-right' + /** 默认主题模式 */ export const DEFAULT_THEME_MODE: ThemeMode = 'dark' +/** 默认消息布局模式 */ +export const DEFAULT_CHAT_MESSAGE_LAYOUT: ChatMessageLayout = 'left-aligned' + /** 应用设置 */ export interface AppSettings { /** 主题模式 */ @@ -44,6 +50,8 @@ export interface AppSettings { agentMaxTurns?: number /** 教程推荐横幅是否已关闭 */ tutorialBannerDismissed?: boolean + /** 消息布局模式 */ + chatMessageLayout?: ChatMessageLayout } /** 持久化的标签页状态 */ diff --git a/bun.lock b/bun.lock index 8eef0a68..1fa03353 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "proma", From b3b343b3a65a0555bc18cf62d9a0f55c58689b6e Mon Sep 17 00:00:00 2001 From: tuandui Date: Tue, 10 Mar 2026 02:11:41 +0800 Subject: [PATCH 2/8] =?UTF-8?q?refactor:=20=E4=BD=BF=E7=94=A8=20useChatLay?= =?UTF-8?q?out=20Hook=20=E7=AE=80=E5=8C=96=E5=B8=83=E5=B1=80=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 useChatLayout 和 useAgentLayout Hooks 统一管理布局样式 - 简化 ChatMessageItem 中的复杂条件判断 - 简化 AgentMessages 中的布局逻辑 - 移除 ChatMessages 中未使用的导入和变量 - 提高代码可读性和可维护性 --- .../components/agent/AgentMessages.tsx | 19 ++-- .../components/chat/ChatMessageItem.tsx | 28 ++--- .../renderer/components/chat/ChatMessages.tsx | 3 - .../src/renderer/hooks/useChatLayout.ts | 100 ++++++++++++++++++ 4 files changed, 118 insertions(+), 32 deletions(-) create mode 100644 apps/electron/src/renderer/hooks/useChatLayout.ts diff --git a/apps/electron/src/renderer/components/agent/AgentMessages.tsx b/apps/electron/src/renderer/components/agent/AgentMessages.tsx index d053ba5b..c0209da6 100644 --- a/apps/electron/src/renderer/components/agent/AgentMessages.tsx +++ b/apps/electron/src/renderer/components/agent/AgentMessages.tsx @@ -35,7 +35,7 @@ import { ToolActivityList } from './ToolActivityItem' import { BackgroundTasksPanel } from './BackgroundTasksPanel' import { useBackgroundTasks } from '@/hooks/useBackgroundTasks' import { userProfileAtom } from '@/atoms/user-profile' -import { chatMessageLayoutAtom } from '@/atoms/chat-message-layout' +import { useAgentLayout } from '@/hooks/useChatLayout' import { cn } from '@/lib/utils' import type { AgentMessage, RetryAttempt } from '@proma/shared' import type { ToolActivity, AgentStreamState } from '@/atoms/agent-atoms' @@ -384,36 +384,35 @@ interface AgentMessageItemProps { function AgentMessageItem({ message, onRetry, onRetryInNewSession }: AgentMessageItemProps): React.ReactElement | null { const userProfile = useAtomValue(userProfileAtom) - const chatMessageLayout = useAtomValue(chatMessageLayoutAtom) - const isLeftRightLayout = chatMessageLayout === 'left-right' if (message.role === 'user') { const { files: attachedFiles, text: messageText } = parseAttachedFiles(message.content) + const layout = useAgentLayout(true) return ( - -
+ +
-
+
{userProfile.userName} {formatMessageTime(message.createdAt)}
- + {attachedFiles.length > 0 && ( -
+
{attachedFiles.map((file) => ( ))}
)} {messageText && ( - {messageText} + {messageText} )} {/* 操作按钮(hover 时可见) */} {messageText && ( - + )} diff --git a/apps/electron/src/renderer/components/chat/ChatMessageItem.tsx b/apps/electron/src/renderer/components/chat/ChatMessageItem.tsx index 3c91d08a..add3a4f7 100644 --- a/apps/electron/src/renderer/components/chat/ChatMessageItem.tsx +++ b/apps/electron/src/renderer/components/chat/ChatMessageItem.tsx @@ -35,7 +35,7 @@ import { InlineEditForm } from './InlineEditForm' import { UserAvatar } from './UserAvatar' import { getModelLogo } from '@/lib/model-logo' import { userProfileAtom } from '@/atoms/user-profile' -import { chatMessageLayoutAtom } from '@/atoms/chat-message-layout' +import { useChatLayout } from '@/hooks/useChatLayout' import type { ChatMessage } from '@proma/shared' import type { InlineEditSubmitPayload } from './InlineEditForm' import { ChatToolActivityIndicator } from './ChatToolActivityIndicator' @@ -107,10 +107,7 @@ export function ChatMessageItem({ const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false) const [isDeleting, setIsDeleting] = React.useState(false) const userProfile = useAtomValue(userProfileAtom) - const chatMessageLayout = useAtomValue(chatMessageLayoutAtom) - - // 是否为左右分布模式 - const isLeftRightLayout = chatMessageLayout === 'left-right' + const layout = useChatLayout(message.role === 'user', isParallelMode) /** 确认删除消息 */ const handleDeleteConfirm = async (): Promise => { @@ -130,16 +127,9 @@ export function ChatMessageItem({ void onSubmitInlineEdit(message, payload) }, [message, onSubmitInlineEdit]) - // 并排模式下,user 消息强制左对齐(忽略左右分布设置) - // 普通模式下,根据布局设置决定:左对齐或左右分布 - const messageFrom = isParallelMode ? 'assistant' : message.role - return ( <> - + {/* assistant 头像 + 模型名 + 时间 */} {message.role === 'assistant' && ( +
-
+
{userProfile.userName} {formatMessageTime(message.createdAt)}
)} - + {message.role === 'assistant' ? ( <> {/* 工具活动记录(历史消息) */} @@ -202,7 +192,7 @@ export function ChatMessageItem({ /* 用户消息 - 附件 + 可折叠文本 / 原地编辑 */ <> {!isInlineEditing && message.attachments && message.attachments.length > 0 && ( - + )} {isInlineEditing ? ( onCancelInlineEdit?.()} /> ) : message.content && ( - {message.content} + {message.content} )} )} @@ -219,7 +209,7 @@ export function ChatMessageItem({ {/* 操作按钮(非 streaming 时显示,hover 时可见) */} {(message.content || (message.attachments && message.attachments.length > 0)) && !isStreaming && !isInlineEditing && ( - + {message.role === 'user' && onResendMessage && ( { + const layout = useAtomValue(chatMessageLayoutAtom) + const isLeftRight = layout === 'left-right' + const isRightAligned = isUserMessage && isLeftRight + + return { + isLeftRight, + layout, + messageAlignClass: isRightAligned ? 'items-end' : 'items-start', + userHeaderFlexClass: isRightAligned ? 'flex-row-reverse' : '', + userInfoAlignClass: isRightAligned ? 'items-end' : '', + contentPaddingClass: isRightAligned ? 'pr-[46px] pl-0' : 'pl-[46px]', + contentItemsClass: isRightAligned ? 'items-end' : '', + actionsClass: isRightAligned ? 'pr-[46px] justify-end' : 'pl-[46px]', + userContentTextClass: isRightAligned ? 'text-right' : '', + attachmentsClass: isRightAligned ? 'items-end' : '', + } +} From d28c3ad8d3b4a4a3e202529edb65c61f854ca66e Mon Sep 17 00:00:00 2001 From: tuandui Date: Thu, 19 Mar 2026 05:08:06 +0800 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E6=B0=94=E6=B3=A1=E9=A2=9C=E8=89=B2=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在外观设置中添加消息气泡颜色配置 - 支持自定义用户消息和 AI 消息的气泡背景色 - 提供颜色选择器和预设颜色快速选择 - 支持恢复默认颜色 - 颜色设置持久化到 settings.json - 使用 CSS 变量实现即时预览效果 --- apps/electron/package.json | 4 +- .../electron/src/main/lib/settings-service.ts | 5 +- apps/electron/src/renderer/atoms/index.ts | 1 + .../renderer/atoms/message-bubble-colors.ts | 75 +++++++++ .../components/agent/AgentMessages.tsx | 59 +++---- .../components/ai-elements/message.tsx | 20 ++- .../components/chat/ChatMessageItem.tsx | 5 +- .../settings/AppearanceSettings.tsx | 132 +++++++++++++--- .../primitives/SettingsColorPicker.tsx | 145 ++++++++++++++++++ .../components/settings/primitives/index.ts | 1 + apps/electron/src/renderer/main.tsx | 20 +++ apps/electron/src/renderer/styles/globals.css | 36 +++++ apps/electron/src/types/settings.ts | 16 ++ 13 files changed, 460 insertions(+), 59 deletions(-) create mode 100644 apps/electron/src/renderer/atoms/message-bubble-colors.ts create mode 100644 apps/electron/src/renderer/components/settings/primitives/SettingsColorPicker.tsx diff --git a/apps/electron/package.json b/apps/electron/package.json index 50835a63..153fc3e8 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -1,6 +1,6 @@ { "name": "@proma/electron", - "version": "0.7.3", + "version": "0.7.4", "description": "Proma next gen ai software with general agents - Electron App", "main": "dist/main.cjs", "author": { @@ -13,7 +13,7 @@ "dev": "concurrently -k -n vite,electron -c blue,green \"bun run dev:vite\" \"bun run dev:electron\"", "dev:split": "bash scripts/dev-split.sh", "dev:vite": "vite dev", - "dev:electron": "bun run build:main && bun run build:preload && bun run build:resources && sleep 2 && concurrently -k -n main,preload,app -c yellow,magenta,cyan \"bun run watch:main\" \"bun run watch:preload\" \"bunx electronmon .\"", + "dev:electron": "bun run build:main && bun run build:preload && bun run build:resources && sleep 2 && concurrently -k -n main,preload,app -c yellow,magenta,cyan \"bun run watch:main\" \"bun run watch:preload\" \"NODE_ENV=development bunx electronmon .\"", "build:main": "esbuild src/main/index.ts --bundle --platform=node --format=cjs --outfile=dist/main.cjs --external:electron --external:@anthropic-ai/claude-agent-sdk", "build:preload": "esbuild src/preload/index.ts --bundle --platform=node --format=cjs --outfile=dist/preload.cjs --external:electron", "watch:main": "esbuild src/main/index.ts --bundle --platform=node --format=cjs --outfile=dist/main.cjs --external:electron --external:@anthropic-ai/claude-agent-sdk --watch=forever", diff --git a/apps/electron/src/main/lib/settings-service.ts b/apps/electron/src/main/lib/settings-service.ts index 184b63a2..81f7341d 100644 --- a/apps/electron/src/main/lib/settings-service.ts +++ b/apps/electron/src/main/lib/settings-service.ts @@ -7,7 +7,7 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs' import { getSettingsPath } from './config-paths' -import { DEFAULT_THEME_MODE, DEFAULT_CHAT_MESSAGE_LAYOUT } from '../../types' +import { DEFAULT_THEME_MODE, DEFAULT_CHAT_MESSAGE_LAYOUT, DEFAULT_MESSAGE_BUBBLE_COLORS } from '../../types' import type { AppSettings } from '../../types' /** @@ -22,6 +22,7 @@ export function getSettings(): AppSettings { return { themeMode: DEFAULT_THEME_MODE, chatMessageLayout: DEFAULT_CHAT_MESSAGE_LAYOUT, + messageBubbleColors: DEFAULT_MESSAGE_BUBBLE_COLORS, onboardingCompleted: false, environmentCheckSkipped: false, notificationsEnabled: true, @@ -35,6 +36,7 @@ export function getSettings(): AppSettings { ...data, themeMode: data.themeMode || DEFAULT_THEME_MODE, chatMessageLayout: data.chatMessageLayout || DEFAULT_CHAT_MESSAGE_LAYOUT, + messageBubbleColors: data.messageBubbleColors || DEFAULT_MESSAGE_BUBBLE_COLORS, onboardingCompleted: data.onboardingCompleted ?? false, environmentCheckSkipped: data.environmentCheckSkipped ?? false, notificationsEnabled: data.notificationsEnabled ?? true, @@ -44,6 +46,7 @@ export function getSettings(): AppSettings { return { themeMode: DEFAULT_THEME_MODE, chatMessageLayout: DEFAULT_CHAT_MESSAGE_LAYOUT, + messageBubbleColors: DEFAULT_MESSAGE_BUBBLE_COLORS, onboardingCompleted: false, environmentCheckSkipped: false, notificationsEnabled: true, diff --git a/apps/electron/src/renderer/atoms/index.ts b/apps/electron/src/renderer/atoms/index.ts index 184f92fc..b694de2f 100644 --- a/apps/electron/src/renderer/atoms/index.ts +++ b/apps/electron/src/renderer/atoms/index.ts @@ -9,3 +9,4 @@ export * from './settings-tab' export * from './chat-atoms' export * from './user-profile' export * from './theme' +export * from './message-bubble-colors' diff --git a/apps/electron/src/renderer/atoms/message-bubble-colors.ts b/apps/electron/src/renderer/atoms/message-bubble-colors.ts new file mode 100644 index 00000000..ffc7d116 --- /dev/null +++ b/apps/electron/src/renderer/atoms/message-bubble-colors.ts @@ -0,0 +1,75 @@ +/** + * 消息气泡颜色状态管理 + * + * 管理用户和 AI 消息气泡的自定义背景颜色。 + * 使用 CSS 变量实现即时预览,持久化到 ~/.proma/settings.json + */ + +import { atom } from 'jotai' +import { DEFAULT_MESSAGE_BUBBLE_COLORS } from '../../types' +import type { MessageBubbleColors } from '../../types' + +/** 消息气泡颜色状态 */ +export const messageBubbleColorsAtom = atom(DEFAULT_MESSAGE_BUBBLE_COLORS) + +/** CSS 变量名常量 */ +const CSS_VAR_USER_MESSAGE = '--user-message-bg' +const CSS_VAR_ASSISTANT_MESSAGE = '--assistant-message-bg' + +/** + * 应用颜色配置到 DOM CSS 变量 + * 注意:当颜色为空字符串时,移除 CSS 变量以让 CSS 使用回退值 + */ +function applyColorsToDOM(colors: MessageBubbleColors): void { + const root = document.documentElement + + if (colors.userMessageColor) { + root.style.setProperty(CSS_VAR_USER_MESSAGE, colors.userMessageColor) + } else { + root.style.removeProperty(CSS_VAR_USER_MESSAGE) + } + + if (colors.assistantMessageColor) { + root.style.setProperty(CSS_VAR_ASSISTANT_MESSAGE, colors.assistantMessageColor) + } else { + root.style.removeProperty(CSS_VAR_ASSISTANT_MESSAGE) + } +} + +/** + * 初始化消息气泡颜色 + * 从主进程加载设置并应用到 DOM + */ +export async function initializeMessageBubbleColors( + setColors: (colors: MessageBubbleColors) => void, +): Promise { + const settings = await window.electronAPI.getSettings() + const colors = settings.messageBubbleColors ?? DEFAULT_MESSAGE_BUBBLE_COLORS + setColors(colors) + applyColorsToDOM(colors) +} + +/** + * 更新消息气泡颜色 + * 更新设置、持久化到文件,并应用到 DOM + */ +export async function updateMessageBubbleColors( + updates: Partial, +): Promise { + const settings = await window.electronAPI.getSettings() + const updated: MessageBubbleColors = { + userMessageColor: settings.messageBubbleColors?.userMessageColor ?? '', + assistantMessageColor: settings.messageBubbleColors?.assistantMessageColor ?? '', + ...updates, + } + await window.electronAPI.updateSettings({ messageBubbleColors: updated }) + applyColorsToDOM(updated) +} + +/** + * 重置消息气泡颜色为默认值 + */ +export async function resetMessageBubbleColors(): Promise { + await window.electronAPI.updateSettings({ messageBubbleColors: DEFAULT_MESSAGE_BUBBLE_COLORS }) + applyColorsToDOM(DEFAULT_MESSAGE_BUBBLE_COLORS) +} diff --git a/apps/electron/src/renderer/components/agent/AgentMessages.tsx b/apps/electron/src/renderer/components/agent/AgentMessages.tsx index 07ed6237..927b8db0 100644 --- a/apps/electron/src/renderer/components/agent/AgentMessages.tsx +++ b/apps/electron/src/renderer/components/agent/AgentMessages.tsx @@ -493,15 +493,17 @@ function AgentMessageItem({ message, sessionPath, onRetry, onRetryInNewSession, logo={} /> - {toolActivities.length > 0 && ( -
- -
- )} - - {message.content && ( - {message.content} - )} +
+ {toolActivities.length > 0 && ( +
+ +
+ )} + + {message.content && ( + {message.content} + )} +
{/* 操作按钮(hover 时可见) */} {message.content && ( @@ -527,8 +529,10 @@ function AgentMessageItem({ message, sessionPath, onRetry, onRetryInNewSession, } /> -
- {message.content} +
+
+ {message.content} +
{/* 错误操作按钮 */}
@@ -649,23 +653,26 @@ export function AgentMessages({ sessionId, messages, streaming, streamState, ses logo={} /> - {retrying && } - {toolActivities.length > 0 && ( -
- - {/* 后台任务面板 — 显示在工具活动下方 */} - + {(retrying || toolActivities.length > 0 || smoothContent) && ( +
+ {retrying && } + {toolActivities.length > 0 && ( +
+ + {/* 后台任务面板 — 显示在工具活动下方 */} + +
+ )} + + {smoothContent ? ( + <> + {smoothContent} + {streaming && } + + ) : null}
)} - - {smoothContent ? ( - <> - {smoothContent} - {streaming && } - - ) : ( - streaming && toolActivities.length === 0 && !retrying && - )} + {streaming && toolActivities.length === 0 && !retrying && !smoothContent && } )} diff --git a/apps/electron/src/renderer/components/ai-elements/message.tsx b/apps/electron/src/renderer/components/ai-elements/message.tsx index 899e1570..5acf64f2 100644 --- a/apps/electron/src/renderer/components/ai-elements/message.tsx +++ b/apps/electron/src/renderer/components/ai-elements/message.tsx @@ -331,8 +331,10 @@ export const MessageResponse = React.memo( /** 折叠行数阈值 */ const COLLAPSE_LINE_THRESHOLD = 4 -/** 将文本中的 @file:路径、/skill:名称、#mcp:名称 替换为样式化 chip */ -const MENTION_RE = /@file:(\S+)|\/skill:(\S+)|#mcp:(\S+)/g +/** 将文本中的 @file:路径、/skill:名称、#mcp:名称 替换为样式化 chip + * 注意:文件路径可能包含空格,使用非贪婪匹配直到遇到下一个 mention 或行尾 + */ +const MENTION_RE = /@file:([\s\S]*?)(?=\s@file:|\s\/skill:|\s#mcp:|$)|\/skill:(\S+)|#mcp:(\S+)/g function renderTextWithMentions(text: string): React.ReactNode { const parts: React.ReactNode[] = [] @@ -352,7 +354,7 @@ function renderTextWithMentions(text: string): React.ReactNode { if (match[1]) { // @file: 文件引用 — 蓝色 chip - const filePath = match[1] + const filePath = match[1].trim() const fileName = filePath.split('/').pop() || filePath parts.push( @@ -425,7 +427,15 @@ export const UserMessageContent = React.memo( const isRightAligned = className?.includes('text-right') return ( -
+
{isExpanded ? ( diff --git a/apps/electron/src/renderer/components/chat/ChatMessageItem.tsx b/apps/electron/src/renderer/components/chat/ChatMessageItem.tsx index 747d6299..f4cfce0c 100644 --- a/apps/electron/src/renderer/components/chat/ChatMessageItem.tsx +++ b/apps/electron/src/renderer/components/chat/ChatMessageItem.tsx @@ -162,7 +162,8 @@ export const ChatMessageItem = React.memo(function ChatMessageItem({ {message.role === 'assistant' ? ( - <> + /* AI 消息 - 使用气泡容器包装内容 */ +
{/* 工具活动记录(历史消息) */} {message.toolActivities && message.toolActivities.length > 0 && ( @@ -196,7 +197,7 @@ export const ChatMessageItem = React.memo(function ChatMessageItem({ {message.attachments && message.attachments.length > 0 && ( )} - +
) : ( /* 用户消息 - 附件 + 可折叠文本 / 原地编辑 */ <> diff --git a/apps/electron/src/renderer/components/settings/AppearanceSettings.tsx b/apps/electron/src/renderer/components/settings/AppearanceSettings.tsx index cf7ade8c..47719dfd 100644 --- a/apps/electron/src/renderer/components/settings/AppearanceSettings.tsx +++ b/apps/electron/src/renderer/components/settings/AppearanceSettings.tsx @@ -2,6 +2,7 @@ * AppearanceSettings - 外观设置页 * * 主题切换(浅色/深色/跟随系统),使用 SettingsSegmentedControl。 + * 消息卡片颜色自定义(用户消息 / AI 消息)。 * 通过 Jotai atom 管理状态,持久化到 ~/.proma/settings.json。 */ @@ -12,8 +13,16 @@ import { SettingsCard, SettingsRow, SettingsSegmentedControl, + SettingsColorPicker, } from './primitives' import { themeModeAtom, updateThemeMode } from '@/atoms/theme' +import { + messageBubbleColorsAtom, + updateMessageBubbleColors, + resetMessageBubbleColors, +} from '@/atoms/message-bubble-colors' +import { Button } from '@/components/ui/button' +import { RotateCcw } from 'lucide-react' import type { ThemeMode } from '../../../types' /** 主题选项 */ @@ -31,32 +40,109 @@ const ZOOM_HINT = isMac export function AppearanceSettings(): React.ReactElement { const [themeMode, setThemeMode] = useAtom(themeModeAtom) + const [messageColors, setMessageColors] = useAtom(messageBubbleColorsAtom) /** 切换主题模式 */ - const handleThemeChange = React.useCallback((value: string) => { - const mode = value as ThemeMode - setThemeMode(mode) - updateThemeMode(mode) - }, [setThemeMode]) + const handleThemeChange = React.useCallback( + (value: string) => { + const mode = value as ThemeMode + setThemeMode(mode) + void updateThemeMode(mode) + }, + [setThemeMode] + ) + + /** 更新用户消息颜色 */ + const handleUserColorChange = React.useCallback( + (color: string) => { + const updated = { ...messageColors, userMessageColor: color } + setMessageColors(updated) + void updateMessageBubbleColors({ userMessageColor: color }) + }, + [messageColors, setMessageColors] + ) + + /** 更新 AI 消息颜色 */ + const handleAssistantColorChange = React.useCallback( + (color: string) => { + const updated = { ...messageColors, assistantMessageColor: color } + setMessageColors(updated) + void updateMessageBubbleColors({ assistantMessageColor: color }) + }, + [messageColors, setMessageColors] + ) + + /** 重置所有颜色 */ + const handleResetAllColors = React.useCallback(() => { + void resetMessageBubbleColors() + setMessageColors({ userMessageColor: '', assistantMessageColor: '' }) + }, [setMessageColors]) + + // 检查是否有自定义颜色 + const hasCustomColors = + messageColors.userMessageColor || messageColors.assistantMessageColor return ( - - - - - - +
+ {/* 主题设置 */} + + + + + + + + {/* 消息气泡颜色设置 */} + + + 恢复默认 + + ) : null + } + > + + + + + + + + + +
) } diff --git a/apps/electron/src/renderer/components/settings/primitives/SettingsColorPicker.tsx b/apps/electron/src/renderer/components/settings/primitives/SettingsColorPicker.tsx new file mode 100644 index 00000000..53775dd6 --- /dev/null +++ b/apps/electron/src/renderer/components/settings/primitives/SettingsColorPicker.tsx @@ -0,0 +1,145 @@ +/** + * SettingsColorPicker - 颜色选择器原语 + * + * 提供颜色选择功能,包括: + * - 原生 color input 选择任意颜色 + * - 预设颜色快速选择 + * - 重置为默认(空值) + */ + +import * as React from 'react' +import { RotateCcw } from 'lucide-react' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' + +/** 预设颜色选项 */ +const PRESET_COLORS = [ + '#3b82f6', // 蓝色 + '#10b981', // 绿色 + '#f59e0b', // 橙色 + '#ef4444', // 红色 + '#8b5cf6', // 紫色 + '#ec4899', // 粉色 + '#06b6d4', // 青色 + '#84cc16', // 青柠 +] + +interface SettingsColorPickerProps { + /** 当前颜色值(空字符串表示使用默认) */ + value: string + /** 颜色变化回调 */ + onChange: (value: string) => void + /** 是否允许清空/重置 */ + allowClear?: boolean +} + +/** + * 颜色选择器组件 + */ +export function SettingsColorPicker({ + value, + onChange, + allowClear = true, +}: SettingsColorPickerProps): React.ReactElement { + const colorInputRef = React.useRef(null) + + /** 处理颜色变化 */ + const handleColorChange = React.useCallback( + (e: React.ChangeEvent) => { + onChange(e.target.value) + }, + [onChange] + ) + + /** 处理预设颜色点击 */ + const handlePresetClick = React.useCallback( + (color: string) => { + onChange(color) + }, + [onChange] + ) + + /** 处理重置 */ + const handleReset = React.useCallback(() => { + onChange('') + }, [onChange]) + + /** 点击颜色预览打开选择器 */ + const handlePreviewClick = React.useCallback(() => { + colorInputRef.current?.click() + }, []) + + // 当前是否有自定义颜色 + const hasCustomColor = value && value !== '' + + return ( +
+ {/* 颜色选择器 */} +
+ {/* 颜色预览/选择按钮 */} + + + {/* 隐藏的原生 color input */} + + + {/* 预设颜色 */} +
+ {PRESET_COLORS.map((color) => ( +
+
+ + {/* 重置按钮 */} + {allowClear && hasCustomColor && ( + + )} +
+ ) +} diff --git a/apps/electron/src/renderer/components/settings/primitives/index.ts b/apps/electron/src/renderer/components/settings/primitives/index.ts index 9d4fc99d..27382c60 100644 --- a/apps/electron/src/renderer/components/settings/primitives/index.ts +++ b/apps/electron/src/renderer/components/settings/primitives/index.ts @@ -13,3 +13,4 @@ export * from './SettingsInput' export * from './SettingsSecretInput' export * from './SettingsSelect' export * from './SettingsSegmentedControl' +export * from './SettingsColorPicker' diff --git a/apps/electron/src/renderer/main.tsx b/apps/electron/src/renderer/main.tsx index b64d6b32..7b1a9ca6 100644 --- a/apps/electron/src/renderer/main.tsx +++ b/apps/electron/src/renderer/main.tsx @@ -38,6 +38,10 @@ import { chatMessageLayoutAtom, initializeChatMessageLayout, } from './atoms/chat-message-layout' +import { + messageBubbleColorsAtom, + initializeMessageBubbleColors, +} from './atoms/message-bubble-colors' import { useGlobalAgentListeners } from './hooks/useGlobalAgentListeners' import { useGlobalChatListeners } from './hooks/useGlobalChatListeners' import { tabsAtom, splitLayoutAtom } from './atoms/tab-atoms' @@ -259,6 +263,21 @@ function ChatMessageLayoutInitializer(): null { return null } +/** + * 消息气泡颜色初始化组件 + * + * 从主进程加载消息气泡颜色设置并应用到 DOM。 + */ +function MessageBubbleColorsInitializer(): null { + const setColors = useSetAtom(messageBubbleColorsAtom) + + useEffect(() => { + void initializeMessageBubbleColors(setColors) + }, [setColors]) + + return null +} + /** * Chat IPC 监听器初始化组件 * @@ -377,6 +396,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render( + diff --git a/apps/electron/src/renderer/styles/globals.css b/apps/electron/src/renderer/styles/globals.css index 5c760b08..45a8611b 100644 --- a/apps/electron/src/renderer/styles/globals.css +++ b/apps/electron/src/renderer/styles/globals.css @@ -23,6 +23,9 @@ --card-foreground: 0 0% 3.9%; --popover: 0 0% 100%; --popover-foreground: 0 0% 3.9%; + /* 消息卡片颜色 CSS 变量(由用户自定义) */ + --user-message-bg: ''; + --assistant-message-bg: ''; } .dark { @@ -191,3 +194,36 @@ .spinner-cube:nth-child(7) { animation-delay: 0s; } .spinner-cube:nth-child(8) { animation-delay: 0.1s; } .spinner-cube:nth-child(9) { animation-delay: 0.2s; } + +/* ===== 消息气泡自定义颜色 ===== */ + +/* 用户消息气泡:优先使用自定义颜色,否则使用默认半透明背景 */ +.user-message-bubble { + background-color: var(--user-message-bg, hsl(var(--foreground) / 0.045)); +} + +.dark .user-message-bubble { + background-color: var(--user-message-bg, hsl(var(--foreground) / 0.08)); +} + +/* AI 消息气泡:优先使用自定义颜色,否则使用透明背景 */ +.assistant-message-bubble { + background-color: var(--assistant-message-bg, transparent); +} + +/* 确保渐变遮罩与自定义颜色协调 */ +.user-message-bubble .collapse-gradient { + background: linear-gradient( + to top, + var(--user-message-bg, hsl(var(--foreground) / 0.045)) 0%, + transparent 100% + ); +} + +.dark .user-message-bubble .collapse-gradient { + background: linear-gradient( + to top, + var(--user-message-bg, hsl(var(--foreground) / 0.08)) 0%, + transparent 100% + ); +} diff --git a/apps/electron/src/types/settings.ts b/apps/electron/src/types/settings.ts index f7649ea7..a7e0faa9 100644 --- a/apps/electron/src/types/settings.ts +++ b/apps/electron/src/types/settings.ts @@ -18,6 +18,20 @@ export const DEFAULT_THEME_MODE: ThemeMode = 'dark' /** 默认消息布局模式 */ export const DEFAULT_CHAT_MESSAGE_LAYOUT: ChatMessageLayout = 'left-aligned' +/** 消息气泡颜色配置 */ +export interface MessageBubbleColors { + /** 用户消息气泡背景色 */ + userMessageColor: string + /** AI 消息气泡背景色 */ + assistantMessageColor: string +} + +/** 默认消息气泡颜色配置(空字符串表示使用默认样式) */ +export const DEFAULT_MESSAGE_BUBBLE_COLORS: MessageBubbleColors = { + userMessageColor: '', + assistantMessageColor: '', +} + /** 应用设置 */ export interface AppSettings { /** 主题模式 */ @@ -52,6 +66,8 @@ export interface AppSettings { tutorialBannerDismissed?: boolean /** 消息布局模式 */ chatMessageLayout?: ChatMessageLayout + /** 消息气泡颜色配置 */ + messageBubbleColors?: MessageBubbleColors } /** 持久化的标签页状态 */ From bc4730ff7931ce4eb9829af216336db9a07dc36e Mon Sep 17 00:00:00 2001 From: tuandui Date: Thu, 19 Mar 2026 05:09:16 +0800 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=E5=A4=9A=E9=A1=B9=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E6=9B=B4=E6=96=B0=E5=92=8C=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Agent 功能增强和 UI 改进 - SidePanel 侧边栏重构 - 文件浏览器功能完善(拖拽、提及、文件树) - Shiki 代码高亮服务优化 - 代码块组件改进 - 富文本输入组件增强 - 标签栏优化 - 更新版本号: - @proma/shared: 0.1.14 -> 0.1.15 - @proma/core: 0.2.2 -> 0.2.3 - @proma/ui: 0.1.3 -> 0.1.4 --- apps/electron/src/main/index.ts | 8 +- apps/electron/src/main/ipc.ts | 73 +- apps/electron/src/preload/index.ts | 34 +- .../src/renderer/atoms/agent-atoms.ts | 16 +- .../renderer/components/agent/AgentView.tsx | 96 +-- .../renderer/components/agent/SidePanel.tsx | 663 ++++++++++++------ .../ai-elements/rich-text-input.tsx | 36 +- .../components/app-shell/LeftSidebar.tsx | 485 +++++++------ .../components/file-browser/FileBrowser.tsx | 12 +- .../components/file-browser/FileDropZone.tsx | 243 +++++-- .../file-browser/FileMentionList.tsx | 196 +++++- .../file-browser/file-mention-suggestion.tsx | 147 +++- .../src/renderer/components/tabs/TabBar.tsx | 5 +- apps/electron/src/renderer/index.html | 1 + packages/core/package.json | 2 +- packages/core/src/highlight/shiki-service.ts | 41 ++ packages/shared/package.json | 2 +- packages/shared/src/types/agent.ts | 36 +- packages/ui/package.json | 2 +- packages/ui/src/code-block/CodeBlock.tsx | 4 +- 20 files changed, 1330 insertions(+), 772 deletions(-) diff --git a/apps/electron/src/main/index.ts b/apps/electron/src/main/index.ts index 9afc8afe..00ba184e 100644 --- a/apps/electron/src/main/index.ts +++ b/apps/electron/src/main/index.ts @@ -112,8 +112,12 @@ function createWindow(): void { }) // Load the renderer - // 强制使用本地构建文件,不依赖开发服务器 - mainWindow.loadFile(join(__dirname, 'renderer', 'index.html')) + // 开发模式使用 Vite 服务器,生产模式使用本地文件 + if (process.env.NODE_ENV === 'development') { + mainWindow.loadURL('http://localhost:5173/') + } else { + mainWindow.loadFile(join(__dirname, 'renderer', 'index.html')) + } // 窗口就绪后最大化显示 mainWindow.once('ready-to-show', () => { diff --git a/apps/electron/src/main/ipc.ts b/apps/electron/src/main/ipc.ts index af504fd6..44861cf6 100644 --- a/apps/electron/src/main/ipc.ts +++ b/apps/electron/src/main/ipc.ts @@ -32,7 +32,6 @@ import type { AgentSaveFilesInput, AgentSaveWorkspaceFilesInput, AgentSavedFile, - AgentAttachDirectoryInput, WorkspaceAttachDirectoryInput, GetTaskOutputInput, GetTaskOutputResult, @@ -1140,41 +1139,46 @@ export function registerIpcHandlers(): void { } ) - // 附加外部目录到 Agent 会话 + // 打开文件或文件夹选择对话框(同时支持) ipcMain.handle( - AGENT_IPC_CHANNELS.ATTACH_DIRECTORY, - async (_, input: AgentAttachDirectoryInput): Promise => { - const meta = getAgentSessionMeta(input.sessionId) - if (!meta) throw new Error(`会话不存在: ${input.sessionId}`) + AGENT_IPC_CHANNELS.OPEN_FILE_OR_FOLDER_DIALOG, + async (): Promise => { + const win = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0] + if (!win) return null - const existing = meta.attachedDirectories ?? [] - if (existing.includes(input.directoryPath)) return existing + const result = await dialog.showOpenDialog(win, { + properties: ['openFile', 'openDirectory', 'multiSelections'], + title: '选择文件或文件夹', + filters: [ + { name: '支持的文件', extensions: ['*'] }, + { name: '所有文件', extensions: ['*'] }, + ], + }) - const updated = [...existing, input.directoryPath] - updateAgentSessionMeta(input.sessionId, { attachedDirectories: updated }) - // 启动附加目录文件监听 - watchAttachedDirectory(input.directoryPath) - return updated - } - ) + if (result.canceled || result.filePaths.length === 0) return null - // 移除会话的附加目录 - ipcMain.handle( - AGENT_IPC_CHANNELS.DETACH_DIRECTORY, - async (_, input: AgentAttachDirectoryInput): Promise => { - const meta = getAgentSessionMeta(input.sessionId) - if (!meta) throw new Error(`会话不存在: ${input.sessionId}`) + const { readFileSync } = await import('node:fs') + const files: Array<{ filename: string; data: string }> = [] + const folders: Array<{ path: string; name: string }> = [] + const { statSync } = await import('node:fs') - const existing = meta.attachedDirectories ?? [] - const updated = existing.filter((d) => d !== input.directoryPath) - updateAgentSessionMeta(input.sessionId, { attachedDirectories: updated }) - // 停止附加目录文件监听 - unwatchAttachedDirectory(input.directoryPath) - return updated + for (const filePath of result.filePaths) { + const stats = statSync(filePath) + if (stats.isDirectory()) { + const name = filePath.split('/').filter(Boolean).pop() || 'folder' + folders.push({ path: filePath, name }) + } else { + const filename = filePath.split('/').filter(Boolean).pop() || 'file' + const buffer = readFileSync(filePath) + files.push({ filename, data: buffer.toString('base64') }) + } + } + + return { files, folders } } ) - // 附加外部目录到工作区(所有会话可访问) + // 关联外部目录到工作区(所有会话可访问) ipcMain.handle( AGENT_IPC_CHANNELS.ATTACH_WORKSPACE_DIRECTORY, async (_, input: WorkspaceAttachDirectoryInput): Promise => { @@ -1428,6 +1432,19 @@ export function registerIpcHandlers(): void { } ) + // 删除附加目录文件/空目录(无工作区路径限制) + ipcMain.handle( + AGENT_IPC_CHANNELS.DELETE_ATTACHED_FILE, + async (_, filePath: string): Promise => { + const { rmSync } = await import('node:fs') + const { resolve } = await import('node:path') + + const safePath = resolve(filePath) + rmSync(safePath, { recursive: true, force: true }) + console.log(`[附加目录] 已删除: ${safePath}`) + } + ) + // 搜索工作区文件(用于 @ 引用,递归扫描,支持附加目录) ipcMain.handle( AGENT_IPC_CHANNELS.SEARCH_WORKSPACE_FILES, diff --git a/apps/electron/src/preload/index.ts b/apps/electron/src/preload/index.ts index b9675ff0..a82fe959 100644 --- a/apps/electron/src/preload/index.ts +++ b/apps/electron/src/preload/index.ts @@ -5,7 +5,7 @@ * 使用上下文隔离确保安全性 */ -import { contextBridge, ipcRenderer } from 'electron' +import { contextBridge, ipcRenderer, webUtils } from 'electron' import { IPC_CHANNELS, CHANNEL_IPC_CHANNELS, CHAT_IPC_CHANNELS, AGENT_IPC_CHANNELS, ENVIRONMENT_IPC_CHANNELS, PROXY_IPC_CHANNELS, GITHUB_RELEASE_IPC_CHANNELS, SYSTEM_PROMPT_IPC_CHANNELS, MEMORY_IPC_CHANNELS, CHAT_TOOL_IPC_CHANNELS, FEISHU_IPC_CHANNELS } from '@proma/shared' import { USER_PROFILE_IPC_CHANNELS, SETTINGS_IPC_CHANNELS } from '../types' import type { @@ -40,7 +40,6 @@ import type { AgentSaveFilesInput, AgentSaveWorkspaceFilesInput, AgentSavedFile, - AgentAttachDirectoryInput, WorkspaceAttachDirectoryInput, GetTaskOutputInput, GetTaskOutputResult, @@ -106,6 +105,9 @@ export interface ElectronAPI { /** 在系统默认浏览器中打开外部链接 */ openExternal: (url: string) => Promise + /** 获取文件的本地路径(用于拖拽文件夹,contextIsolation 安全方式) */ + getFilePath: (file: File) => string + // ===== 渠道管理相关 ===== /** 获取所有渠道列表(apiKey 保持加密态) */ @@ -433,13 +435,10 @@ export interface ElectronAPI { /** 打开文件夹选择对话框 */ openFolderDialog: () => Promise<{ path: string; name: string } | null> - /** 附加外部目录到 Agent 会话 */ - attachDirectory: (input: AgentAttachDirectoryInput) => Promise - - /** 移除会话的附加目录 */ - detachDirectory: (input: AgentAttachDirectoryInput) => Promise + /** 打开文件或文件夹选择对话框(同时支持) */ + openFileOrFolderDialog: () => Promise - /** 附加外部目录到工作区(所有会话可访问) */ + /** 关联外部目录到工作区(所有会话可访问) */ attachWorkspaceDirectory: (input: WorkspaceAttachDirectoryInput) => Promise /** 移除工作区的附加目录 */ @@ -489,6 +488,9 @@ export interface ElectronAPI { /** 移动附加目录文件/目录(无工作区路径限制) */ moveAttachedFile: (filePath: string, targetDir: string) => Promise + /** 删除附加目录文件/空目录(无工作区路径限制) */ + deleteAttachedFile: (filePath: string) => Promise + /** 搜索工作区文件(用于 @ 引用,支持附加目录) */ searchWorkspaceFiles: (rootPath: string, query: string, limit?: number, additionalPaths?: string[]) => Promise @@ -590,6 +592,10 @@ const electronAPI: ElectronAPI = { return ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, url) }, + getFilePath: (file: File) => { + return webUtils.getPathForFile(file) + }, + // 渠道管理 listChannels: () => { return ipcRenderer.invoke(CHANNEL_IPC_CHANNELS.LIST) @@ -1034,12 +1040,8 @@ const electronAPI: ElectronAPI = { return ipcRenderer.invoke(AGENT_IPC_CHANNELS.OPEN_FOLDER_DIALOG) }, - attachDirectory: (input: AgentAttachDirectoryInput) => { - return ipcRenderer.invoke(AGENT_IPC_CHANNELS.ATTACH_DIRECTORY, input) - }, - - detachDirectory: (input: AgentAttachDirectoryInput) => { - return ipcRenderer.invoke(AGENT_IPC_CHANNELS.DETACH_DIRECTORY, input) + openFileOrFolderDialog: () => { + return ipcRenderer.invoke(AGENT_IPC_CHANNELS.OPEN_FILE_OR_FOLDER_DIALOG) }, attachWorkspaceDirectory: (input: WorkspaceAttachDirectoryInput) => { @@ -1107,6 +1109,10 @@ const electronAPI: ElectronAPI = { return ipcRenderer.invoke(AGENT_IPC_CHANNELS.MOVE_ATTACHED_FILE, filePath, targetDir) }, + deleteAttachedFile: (filePath: string) => { + return ipcRenderer.invoke(AGENT_IPC_CHANNELS.DELETE_ATTACHED_FILE, filePath) + }, + searchWorkspaceFiles: (rootPath: string, query: string, limit = 20, additionalPaths?: string[]) => { return ipcRenderer.invoke(AGENT_IPC_CHANNELS.SEARCH_WORKSPACE_FILES, rootPath, query, limit, additionalPaths) }, diff --git a/apps/electron/src/renderer/atoms/agent-atoms.ts b/apps/electron/src/renderer/atoms/agent-atoms.ts index 0be63051..ac28e615 100644 --- a/apps/electron/src/renderer/atoms/agent-atoms.ts +++ b/apps/electron/src/renderer/atoms/agent-atoms.ts @@ -563,6 +563,13 @@ export const agentSidePanelOpenMapAtom = atom>(new Map()) /** 侧面板当前活跃 Tab(per-session Map) */ export const agentSidePanelTabMapAtom = atom>(new Map()) +/** 本会话文件拖拽区域是否已收起(per-session Map) + * + * 用于控制本会话文件区域的 FileDropZone 显示。 + * 与 workspaceFilesVersionAtom 独立,避免工作区文件变化影响本会话文件的上传提示。 + */ +export const agentSessionDropZoneDismissedAtom = atom>(new Map()) + /** * Team 活动缓存 — 以 sessionId 为 key * @@ -1165,14 +1172,7 @@ export const currentAgentErrorAtom = atom((get) => { export const agentSessionDraftsAtom = atom>(new Map()) /** - * 会话附加目录 Map — 以 sessionId 为 key - * 存储每个会话通过"附加文件夹"功能关联的外部目录路径列表。 - * 这些路径作为 SDK additionalDirectories 参数传递。 - */ -export const agentAttachedDirectoriesMapAtom = atom>(new Map()) - -/** - * 工作区级附加目录列表(按 workspaceId 存储) + * 工作区级关联目录列表(按 workspaceId 存储) * * 工作区内所有会话共享这些附加目录。 */ diff --git a/apps/electron/src/renderer/components/agent/AgentView.tsx b/apps/electron/src/renderer/components/agent/AgentView.tsx index c43d1fac..afcf41f0 100644 --- a/apps/electron/src/renderer/components/agent/AgentView.tsx +++ b/apps/electron/src/renderer/components/agent/AgentView.tsx @@ -51,8 +51,6 @@ import { dismissedTeamSessionIdsAtom, buildTeamActivityEntries, rebuildTeamDataFromMessages, - agentAttachedDirectoriesMapAtom, - workspaceAttachedDirectoriesMapAtom, } from '@/atoms/agent-atoms' import type { AgentContextStatus } from '@/atoms/agent-atoms' import { activeViewAtom } from '@/atoms/active-view' @@ -90,12 +88,6 @@ export function AgentView({ sessionId }: { sessionId: string }): React.ReactElem const setCurrentAgentSessionId = useSetAtom(currentAgentSessionIdAtom) const [tabs, setTabs] = useAtom(tabsAtom) const [layout, setLayout] = useAtom(splitLayoutAtom) - const setAttachedDirsMap = useSetAtom(agentAttachedDirectoriesMapAtom) - const attachedDirsMap = useAtomValue(agentAttachedDirectoriesMapAtom) - const attachedDirs = attachedDirsMap.get(sessionId) ?? [] - const wsAttachedDirsMap = useAtomValue(workspaceAttachedDirectoriesMapAtom) - const wsAttachedDirs = currentWorkspaceId ? (wsAttachedDirsMap.get(currentWorkspaceId) ?? []) : [] - const draftsMap = useAtomValue(agentSessionDraftsAtom) const setDraftsMap = useSetAtom(agentSessionDraftsAtom) const inputContent = draftsMap.get(sessionId) ?? '' @@ -148,9 +140,13 @@ export function AgentView({ sessionId }: { sessionId: string }): React.ReactElem return } + console.log('[AgentView] Getting session path for:', sessionId, currentWorkspaceId) window.electronAPI .getAgentSessionPath(currentWorkspaceId, sessionId) - .then(setSessionPath) + .then((path) => { + console.log('[AgentView] Session path:', path) + setSessionPath(path) + }) .catch(() => setSessionPath(null)) }, [sessionId, currentWorkspaceId]) @@ -167,19 +163,7 @@ export function AgentView({ sessionId }: { sessionId: string }): React.ReactElem .catch(() => setWorkspaceFilesPath(null)) }, [workspaceSlug]) - // 合并工作区文件目录、工作区级附加目录和会话级附加目录,供 @ 引用搜索 - const allAttachedDirs = React.useMemo(() => { - const dirs = [...attachedDirs] - // 添加工作区级附加目录 - for (const d of wsAttachedDirs) { - if (!dirs.includes(d)) dirs.push(d) - } - // 添加工作区共享文件目录 - if (workspaceFilesPath && !dirs.includes(workspaceFilesPath)) { - dirs.unshift(workspaceFilesPath) - } - return dirs - }, [attachedDirs, wsAttachedDirs, workspaceFilesPath]) + // 监听消息刷新版本号 const refreshMap = useAtomValue(agentMessageRefreshAtom) @@ -232,25 +216,6 @@ export function AgentView({ sessionId }: { sessionId: string }): React.ReactElem .catch(console.error) }, [sessionId, refreshVersion, setStreamingStates, store]) - // 从会话元数据初始化附加目录 - const sessions = useAtomValue(agentSessionsAtom) - React.useEffect(() => { - const meta = sessions.find((s) => s.id === sessionId) - const dirs = meta?.attachedDirectories ?? [] - setAttachedDirsMap((prev) => { - const existing = prev.get(sessionId) - // 避免不必要的更新 - if (JSON.stringify(existing) === JSON.stringify(dirs)) return prev - const map = new Map(prev) - if (dirs.length > 0) { - map.set(sessionId, dirs) - } else { - map.delete(sessionId) - } - return map - }) - }, [sessionId, sessions, setAttachedDirsMap]) - // 自动发送 pending prompt(从设置页"对话完成配置"触发) React.useEffect(() => { if (!pendingPrompt) return @@ -385,30 +350,6 @@ export function AgentView({ sessionId }: { sessionId: string }): React.ReactElem } }, [setPendingFiles]) - /** 附加文件夹(不复制,仅记录路径) */ - const handleAttachFolder = React.useCallback(async (): Promise => { - try { - const result = await window.electronAPI.openFolderDialog() - if (!result) return - - const updated = await window.electronAPI.attachDirectory({ - sessionId, - directoryPath: result.path, - }) - - setAttachedDirsMap((prev) => { - const map = new Map(prev) - map.set(sessionId, updated) - return map - }) - - toast.success(`已附加目录: ${result.name}`) - } catch (error) { - console.error('[AgentView] 附加文件夹失败:', error) - toast.error('附加文件夹失败') - } - }, [sessionId, setAttachedDirsMap]) - /** 移除待发送文件 */ const handleRemoveFile = React.useCallback((id: string): void => { setPendingFiles((prev) => { @@ -611,7 +552,6 @@ export function AgentView({ sessionId }: { sessionId: string }): React.ReactElem channelId: agentChannelId, modelId: agentModelId || undefined, workspaceId: currentWorkspaceId || undefined, - ...(attachedDirs.length > 0 && { additionalDirectories: attachedDirs }), // 解析用户消息中的 Skill/MCP 引用,传递结构化元数据给后端 ...(() => { const skills = [...effectiveText.matchAll(/\/skill:(\S+)/g)].map(m => m[1]).filter(Boolean) as string[] @@ -634,7 +574,7 @@ export function AgentView({ sessionId }: { sessionId: string }): React.ReactElem return map }) }) - }, [inputContent, pendingFiles, attachedDirs, sessionId, agentChannelId, agentModelId, currentWorkspaceId, workspaces, streaming, suggestion, store, setStreamingStates, setPendingFiles, setAgentStreamErrors, setPromptSuggestions, setInputContent]) + }, [inputContent, pendingFiles, sessionId, agentChannelId, agentModelId, currentWorkspaceId, workspaces, streaming, suggestion, store, setStreamingStates, setPendingFiles, setAgentStreamErrors, setPromptSuggestions, setInputContent]) /** 停止生成 */ const handleStop = React.useCallback((): void => { @@ -800,7 +740,7 @@ export function AgentView({ sessionId }: { sessionId: string }): React.ReactElem {dragFolderWarning && (
- 不支持拖拽文件夹,请使用"附加文件夹"按钮 + 不支持拖拽文件夹,请在右侧「工作区文件」中关联 - - -

附加文件夹

-
- 0 + // 工作区文件上传后,刷新文件列表 + const handleWorkspaceFilesUploaded = React.useCallback(() => { + setFilesVersion((prev) => prev + 1) + }, [setFilesVersion]) + // 派生当前工作区 slug(用于 FileDropZone IPC 调用) const currentWorkspaceId = useAtomValue(currentAgentWorkspaceIdAtom) const workspaces = useAtomValue(agentWorkspacesAtom) const workspaceSlug = workspaces.find((w) => w.id === currentWorkspaceId)?.slug ?? null - // 附加目录列表(会话级) - const attachedDirsMap = useAtomValue(agentAttachedDirectoriesMapAtom) - const setAttachedDirsMap = useSetAtom(agentAttachedDirectoriesMapAtom) - const attachedDirs = attachedDirsMap.get(sessionId) ?? [] - - // 附加目录列表(工作区级) + // 工作区文件列表(关联的外部文件夹) const wsAttachedDirsMap = useAtomValue(workspaceAttachedDirectoriesMapAtom) const setWsAttachedDirsMap = useSetAtom(workspaceAttachedDirectoriesMapAtom) + + // 文件夹关联成功后更新状态 + const handleFoldersAttached = React.useCallback((updatedDirs: string[]) => { + if (!currentWorkspaceId) return + setWsAttachedDirsMap((prev) => { + const map = new Map(prev) + map.set(currentWorkspaceId, updatedDirs) + return map + }) + }, [currentWorkspaceId, setWsAttachedDirsMap]) const wsAttachedDirs = currentWorkspaceId ? (wsAttachedDirsMap.get(currentWorkspaceId) ?? []) : [] - // 加载工作区级附加目录 + // 工作区文件目录路径(用于显示上传的文件) + const [workspaceFilesPath, setWorkspaceFilesPath] = React.useState(null) + + // 加载工作区文件列表(关联的外部文件夹 + workspace-files 目录) React.useEffect(() => { if (!workspaceSlug || !currentWorkspaceId) return - window.electronAPI.getWorkspaceDirectories(workspaceSlug) - .then((dirs) => { - setWsAttachedDirsMap((prev) => { - const map = new Map(prev) - map.set(currentWorkspaceId, dirs) - return map - }) + + // 获取关联的目录和 workspace-files 路径 + Promise.all([ + window.electronAPI.getWorkspaceDirectories(workspaceSlug), + window.electronAPI.getWorkspaceFilesPath(workspaceSlug), + ]).then(([dirs, filesPath]) => { + setWsAttachedDirsMap((prev) => { + const map = new Map(prev) + map.set(currentWorkspaceId, dirs) + return map }) - .catch(console.error) + setWorkspaceFilesPath(filesPath) + }).catch(console.error) }, [workspaceSlug, currentWorkspaceId, setWsAttachedDirsMap]) - const handleAttachFolder = React.useCallback(async () => { + // 关联工作区文件或文件夹(外部文件/文件夹) + const handleAttachWorkspaceFilesOrFolders = React.useCallback(async () => { + if (!workspaceSlug || !currentWorkspaceId) return try { - const result = await window.electronAPI.openFolderDialog() + const result = await window.electronAPI.openFileOrFolderDialog() if (!result) return - const updated = await window.electronAPI.attachDirectory({ - sessionId, - directoryPath: result.path, - }) - setAttachedDirsMap((prev) => { - const map = new Map(prev) - map.set(sessionId, updated) - return map - }) - } catch (error) { - console.error('[SidePanel] 附加文件夹失败:', error) - } - }, [sessionId, setAttachedDirsMap]) - - const handleDetachDirectory = React.useCallback(async (dirPath: string) => { - try { - const updated = await window.electronAPI.detachDirectory({ - sessionId, - directoryPath: dirPath, - }) - setAttachedDirsMap((prev) => { - const map = new Map(prev) - if (updated.length > 0) { - map.set(sessionId, updated) - } else { - map.delete(sessionId) + // 处理文件夹关联 + if (result.folders.length > 0) { + let updated = wsAttachedDirsMap.get(currentWorkspaceId) ?? [] + for (const folder of result.folders) { + updated = await window.electronAPI.attachWorkspaceDirectory({ + workspaceSlug, + directoryPath: folder.path, + }) } - return map - }) - } catch (error) { - console.error('[SidePanel] 移除附加目录失败:', error) - } - }, [sessionId, setAttachedDirsMap]) + setWsAttachedDirsMap((prev) => { + const map = new Map(prev) + map.set(currentWorkspaceId, updated) + return map + }) + } - // 工作区级附加文件夹 - const handleAttachWorkspaceFolder = React.useCallback(async () => { - if (!workspaceSlug || !currentWorkspaceId) return - try { - const result = await window.electronAPI.openFolderDialog() - if (!result) return + // 处理文件上传 + if (result.files.length > 0) { + await window.electronAPI.saveFilesToWorkspaceFiles({ + workspaceSlug, + files: result.files, + }) + // 刷新文件列表 + setFilesVersion((prev) => prev + 1) + } - const updated = await window.electronAPI.attachWorkspaceDirectory({ - workspaceSlug, - directoryPath: result.path, - }) - setWsAttachedDirsMap((prev) => { - const map = new Map(prev) - map.set(currentWorkspaceId, updated) - return map - }) + // 显示提示 + const folderCount = result.folders.length + const fileCount = result.files.length + if (folderCount > 0 && fileCount > 0) { + toast.success(`已关联 ${folderCount} 个文件夹,上传 ${fileCount} 个文件`) + } else if (folderCount > 0) { + toast.success(`已关联 ${folderCount} 个文件夹`) + } else if (fileCount > 0) { + toast.success(`已上传 ${fileCount} 个文件`) + } } catch (error) { - console.error('[SidePanel] 附加工作区文件夹失败:', error) + console.error('[SidePanel] 关联失败:', error) + toast.error('关联失败') } - }, [workspaceSlug, currentWorkspaceId, setWsAttachedDirsMap]) + }, [workspaceSlug, currentWorkspaceId, wsAttachedDirsMap, setWsAttachedDirsMap, setFilesVersion]) const handleDetachWorkspaceDirectory = React.useCallback(async (dirPath: string) => { if (!workspaceSlug || !currentWorkspaceId) return @@ -208,7 +225,7 @@ export function SidePanel({ sessionId, sessionPath }: SidePanelProps): React.Rea return map }) } catch (error) { - console.error('[SidePanel] 移除工作区附加目录失败:', error) + console.error('[SidePanel] 移除工作区文件夹失败:', error) } }, [workspaceSlug, currentWorkspaceId, setWsAttachedDirsMap]) @@ -222,22 +239,51 @@ export function SidePanel({ sessionId, sessionPath }: SidePanelProps): React.Rea setFilesVersion((prev) => prev + 1) }, [setFilesVersion]) - // 面包屑:显示根路径最后两段 - const breadcrumb = React.useMemo(() => { - if (!sessionPath) return '' - const parts = sessionPath.split('/').filter(Boolean) - return parts.length > 2 ? `.../${parts.slice(-2).join('/')}` : sessionPath - }, [sessionPath]) + // ===== 文件模块展开/折叠状态和高度计算 ===== + const [sessionFilesExpanded, setSessionFilesExpanded] = React.useState(true) + const [workspaceFilesExpanded, setWorkspaceFilesExpanded] = React.useState(true) + const [sessionFilesCount, setSessionFilesCount] = React.useState(0) - // 工作区文件目录路径 - const [workspaceFilesPath, setWorkspaceFilesPath] = React.useState(null) + // 监听本会话文件数量变化 React.useEffect(() => { - if (!workspaceSlug) { - setWorkspaceFilesPath(null) + if (!sessionPath) { + setSessionFilesCount(0) return } - window.electronAPI.getWorkspaceFilesPath(workspaceSlug).then(setWorkspaceFilesPath).catch(() => setWorkspaceFilesPath(null)) - }, [workspaceSlug]) + window.electronAPI.listDirectory(sessionPath) + .then(items => setSessionFilesCount(items.length)) + .catch(() => setSessionFilesCount(0)) + }, [sessionPath, filesVersion]) + + // 计算本会话文件区域高度(自适应,最大50%) + const sessionSectionStyle = React.useMemo(() => { + if (!sessionFilesExpanded) { + return { height: '32px' } // 仅标题栏高度 + } + + const headerHeight = 32 // 标题栏高度 + const itemHeight = 28 // 每个文件项高度 + const maxHeightPercent = 50 // 最大占50% + + if (sessionFilesCount === 0) { + return { height: `${headerHeight}px` } + } + + // 计算内容高度(最多显示5个文件项的高度) + const maxVisibleItems = 5 + const visibleItems = Math.min(sessionFilesCount, maxVisibleItems) + const contentHeight = visibleItems * itemHeight + const totalHeight = headerHeight + contentHeight + + // 计算百分比高度(基于面板高度约640px) + const containerHeight = 640 + const calculatedPercent = (totalHeight / containerHeight) * 100 + + return { + height: `${Math.min(calculatedPercent, maxHeightPercent)}%`, + minHeight: `${headerHeight + itemHeight}px` // 至少显示一个文件项 + } + }, [sessionFilesCount, sessionFilesExpanded]) // 自动打开:文件变化时(仅在有 sessionPath 时) const prevFilesVersionRef = React.useRef(filesVersion) @@ -253,7 +299,7 @@ export function SidePanel({ sessionId, sessionPath }: SidePanelProps): React.Rea }, [filesVersion, sessionPath, hasTeamActivity, setIsOpen, setActiveTab]) // 面板是否可显示内容(需要有 sessionPath 或 team 活动) - const hasContent = sessionPath || hasTeamActivity || attachedDirs.length > 0 + const hasContent = sessionPath || hasTeamActivity return (
{sessionPath && workspaceSlug ? ( -
- {/* ===== 会话文件区 ===== */} -
- - 会话文件 - - - - - -

当前会话的专属文件,仅本次对话的 Agent 可以访问

-
-
- - {breadcrumb} - - - - - - -

在 Finder 中打开

-
-
- - - - - -

刷新文件列表

-
-
+
+ {/* ===== 本会话文件区(上方,自适应高度,最大50%) ===== */} +
+ {/* 标题栏 - 可点击展开/折叠 */} +
setSessionFilesExpanded(v => !v)} + > + {sessionFilesExpanded ? ( + + ) : ( + + )} + + 本会话文件 + {sessionFilesCount > 0 && ( + ({sessionFilesCount}) + )} + + + + + +

当前会话的专属文件,仅本次对话的 Agent 可以访问,新对话不继承

+
+
+
+ + + + + +

在 Finder 中打开

+
+
+ + + + + +

刷新文件列表

+
+
+
+ {/* 本会话文件浏览器(可滚动)- 仅查看对话中上传的附件 */} + + + + +
- {/* 附加目录列表(可展开目录树) */} - {attachedDirs.length > 0 && ( - - )} - {/* 会话文件浏览器 */} - - {/* 会话文件拖拽上传区域 */} - - - {/* ===== 分隔线 ===== */} -
- - {/* ===== 工作区文件区 ===== */} -
-
- + + {/* ===== 工作区文件区(下方,占据剩余空间) ===== */} +
+ {/* 标题栏 - 可点击展开/折叠 */} +
setWorkspaceFilesExpanded(v => !v)} + > + {workspaceFilesExpanded ? ( + + ) : ( + + )} + 工作区文件 - + -

工作区内所有会话可访问的文件和文件夹,每个新对话都可以自动读取

+

关联的外部文件夹,工作区内所有会话可访问,Agent 可直接修改原位置的文件

- {workspaceFilesPath && ( - - - - - -

在 Finder 中打开工作区文件目录

-
-
- )} + + + + + +

关联文件或文件夹

+
+
- {/* 工作区级附加目录 */} - {wsAttachedDirs.length > 0 && ( - - )} - {/* 工作区文件浏览器 */} - {workspaceFilesPath && ( - - )} - {/* 工作区文件拖拽上传区域 */} - + {/* 工作区文件列表(可滚动) - 支持拖拽 */} + + + 0 || !!workspaceFilesPath} + > + {(wsAttachedDirs.length > 0 || workspaceFilesPath) ? ( + + ) : null} + + +
) : ( @@ -478,78 +534,133 @@ export function SidePanel({ sessionId, sessionPath }: SidePanelProps): React.Rea ) } -// ===== 附加目录容器(管理选中状态) ===== +// ===== 工作区文件容器(关联的外部文件夹) ===== -interface AttachedDirsSectionProps { +interface WorkspaceFilesSectionProps { attachedDirs: string[] onDetach: (dirPath: string) => void /** 文件版本号,用于自动刷新已展开的目录 */ refreshVersion: number + /** workspace-files 目录路径(用于显示上传的文件) */ + workspaceFilesPath: string | null } -/** 附加目录区域:统一管理所有子项的选中状态 */ -function AttachedDirsSection({ attachedDirs, onDetach, refreshVersion }: AttachedDirsSectionProps): React.ReactElement { +/** 工作区文件区域:统一管理所有关联的外部文件夹和上传的文件 */ +function WorkspaceFilesSection({ attachedDirs, onDetach, refreshVersion, workspaceFilesPath }: WorkspaceFilesSectionProps): React.ReactElement { const [selectedPaths, setSelectedPaths] = React.useState>(new Set()) + // 最后选中的路径(用于 Shift 范围选择的锚点) + const lastSelectedPathRef = React.useRef(null) + // 所有可见文件路径(用于 Shift 范围选择) + const allVisiblePathsRef = React.useRef>(new Set()) + + // 注册/注销可见路径 + const registerVisiblePath = React.useCallback((path: string) => { + allVisiblePathsRef.current.add(path) + }, []) + const unregisterVisiblePath = React.useCallback((path: string) => { + allVisiblePathsRef.current.delete(path) + }, []) - const handleSelect = React.useCallback((path: string, ctrlKey: boolean) => { + const handleSelect = React.useCallback((path: string, ctrlKey: boolean, shiftKey: boolean) => { setSelectedPaths((prev) => { + if (shiftKey && lastSelectedPathRef.current && allVisiblePathsRef.current.size > 0) { + // Shift+点击:范围选择 + const allPaths = Array.from(allVisiblePathsRef.current) + const anchorIndex = allPaths.indexOf(lastSelectedPathRef.current) + const targetIndex = allPaths.indexOf(path) + if (anchorIndex !== -1 && targetIndex !== -1) { + const start = Math.min(anchorIndex, targetIndex) + const end = Math.max(anchorIndex, targetIndex) + const rangePaths = allPaths.slice(start, end + 1) + // 保持之前选中的,添加范围内的 + return new Set([...prev, ...rangePaths]) + } + } + if (ctrlKey) { // Ctrl+点击:切换选中 const next = new Set(prev) if (next.has(path)) { next.delete(path) + if (lastSelectedPathRef.current === path) { + lastSelectedPathRef.current = null + } } else { next.add(path) + lastSelectedPathRef.current = path } return next } + // 普通点击:单选 + lastSelectedPathRef.current = path return new Set([path]) }) }, []) return ( -
-
附加目录(Agent 可以读取并操作此文件夹)
+
+ {/* workspace-files 目录(显示上传的文件) */} + {workspaceFilesPath && ( + {}} // 不可分离 + selectedPaths={selectedPaths} + onSelect={handleSelect} + refreshVersion={refreshVersion} + isSystemDir + registerVisiblePath={registerVisiblePath} + unregisterVisiblePath={unregisterVisiblePath} + /> + )} {attachedDirs.map((dir) => ( - onDetach(dir)} selectedPaths={selectedPaths} onSelect={handleSelect} refreshVersion={refreshVersion} + registerVisiblePath={registerVisiblePath} + unregisterVisiblePath={unregisterVisiblePath} /> ))}
) } -// ===== 附加目录树组件 ===== +// ===== 工作区目录树组件 ===== -interface AttachedDirTreeProps { +interface WorkspaceDirTreeProps { dirPath: string onDetach: () => void selectedPaths: Set - onSelect: (path: string, ctrlKey: boolean) => void + onSelect: (path: string, ctrlKey: boolean, shiftKey: boolean) => void /** 文件版本号,变化时已展开的目录自动重新加载 */ refreshVersion: number + /** 是否为系统目录(workspace-files/),不可分离,显示特殊名称 */ + isSystemDir?: boolean + /** 注册可见路径(用于 Shift 范围选择) */ + registerVisiblePath?: (path: string) => void + /** 注销可见路径 */ + unregisterVisiblePath?: (path: string) => void } -/** 附加目录根节点:可展开/收起,带移除按钮 */ -function AttachedDirTree({ dirPath, onDetach, selectedPaths, onSelect, refreshVersion }: AttachedDirTreeProps): React.ReactElement { +/** 工作区目录根节点:可展开/收起,带移除按钮 */ +function WorkspaceDirTree({ dirPath, onDetach, selectedPaths, onSelect, refreshVersion, isSystemDir, registerVisiblePath, unregisterVisiblePath }: WorkspaceDirTreeProps): React.ReactElement { const [expanded, setExpanded] = React.useState(false) const [children, setChildren] = React.useState([]) const [loaded, setLoaded] = React.useState(false) - const dirName = dirPath.split('/').filter(Boolean).pop() || dirPath + const dirName = isSystemDir ? '上传文件' : (dirPath.split('/').filter(Boolean).pop() || dirPath) // 当 refreshVersion 变化时,已展开的目录自动重新加载 React.useEffect(() => { if (expanded && loaded) { window.electronAPI.listAttachedDirectory(dirPath) .then((items) => setChildren(items)) - .catch((err) => console.error('[AttachedDirTree] 刷新失败:', err)) + .catch((err) => console.error('[WorkspaceDirTree] 刷新失败:', err)) } }, [refreshVersion]) // eslint-disable-line react-hooks/exhaustive-deps @@ -560,12 +671,21 @@ function AttachedDirTree({ dirPath, onDetach, selectedPaths, onSelect, refreshVe setChildren(items) setLoaded(true) } catch (err) { - console.error('[AttachedDirTree] 加载失败:', err) + console.error('[WorkspaceDirTree] 加载失败:', err) } } setExpanded(!expanded) } + // 处理根节点点击(支持 Shift 范围选择) + const handleClick = (e: React.MouseEvent) => { + // Shift 多选时阻止默认文本选择行为 + if (e.shiftKey) { + e.preventDefault() + } + onSelect(dirPath, e.ctrlKey || e.metaKey, e.shiftKey) + } + return (
{dirName} - + {!isSystemDir && ( + + )}
{expanded && children.length === 0 && loaded && (
@@ -602,23 +724,36 @@ function AttachedDirTree({ dirPath, onDetach, selectedPaths, onSelect, refreshVe
)} {expanded && children.map((child) => ( - + ))}
) } -interface AttachedDirItemProps { +interface WorkspaceDirItemProps { entry: FileEntry depth: number selectedPaths: Set - onSelect: (path: string, ctrlKey: boolean) => void + onSelect: (path: string, ctrlKey: boolean, shiftKey: boolean) => void /** 文件版本号,变化时已展开的目录自动重新加载 */ refreshVersion: number + /** 注册可见路径(用于 Shift 范围选择) */ + registerVisiblePath?: (path: string) => void + /** 注销可见路径 */ + unregisterVisiblePath?: (path: string) => void } -/** 附加目录子项:递归可展开,支持选中 + 三点菜单(含重命名、移动) */ -function AttachedDirItem({ entry, depth, selectedPaths, onSelect, refreshVersion }: AttachedDirItemProps): React.ReactElement { +/** 工作区目录子项:递归可展开,支持选中 + 三点菜单(含重命名、移动) */ +function WorkspaceDirItem({ entry, depth, selectedPaths, onSelect, refreshVersion, registerVisiblePath, unregisterVisiblePath }: WorkspaceDirItemProps): React.ReactElement { const [expanded, setExpanded] = React.useState(false) const [children, setChildren] = React.useState([]) const [loaded, setLoaded] = React.useState(false) @@ -629,15 +764,25 @@ function AttachedDirItem({ entry, depth, selectedPaths, onSelect, refreshVersion // 当前显示的名称和路径(重命名后更新) const [currentName, setCurrentName] = React.useState(entry.name) const [currentPath, setCurrentPath] = React.useState(entry.path) + // 删除确认状态 + const [showDeleteDialog, setShowDeleteDialog] = React.useState(false) const isSelected = selectedPaths.has(currentPath) + // 注册/注销可见路径(用于 Shift 范围选择) + React.useEffect(() => { + registerVisiblePath?.(currentPath) + return () => { + unregisterVisiblePath?.(currentPath) + } + }, [currentPath]) + // 当 refreshVersion 变化时,已展开的文件夹自动重新加载子项 React.useEffect(() => { if (expanded && loaded && entry.isDirectory) { window.electronAPI.listAttachedDirectory(currentPath) .then((items) => setChildren(items)) - .catch((err) => console.error('[AttachedDirItem] 刷新子目录失败:', err)) + .catch((err) => console.error('[WorkspaceDirItem] 刷新子目录失败:', err)) } }, [refreshVersion]) // eslint-disable-line react-hooks/exhaustive-deps @@ -649,15 +794,19 @@ function AttachedDirItem({ entry, depth, selectedPaths, onSelect, refreshVersion setChildren(items) setLoaded(true) } catch (err) { - console.error('[AttachedDirItem] 加载子目录失败:', err) + console.error('[WorkspaceDirItem] 加载子目录失败:', err) } } setExpanded(!expanded) } const handleClick = (e: React.MouseEvent): void => { - onSelect(currentPath, e.ctrlKey || e.metaKey) - if (entry.isDirectory) { + // Shift 多选时阻止默认文本选择行为 + if (e.shiftKey) { + e.preventDefault() + } + onSelect(currentPath, e.ctrlKey || e.metaKey, e.shiftKey) + if (entry.isDirectory && !e.shiftKey && !e.ctrlKey && !e.metaKey) { toggleDir() } } @@ -689,11 +838,11 @@ function AttachedDirItem({ entry, depth, selectedPaths, onSelect, refreshVersion const parentDir = currentPath.substring(0, currentPath.lastIndexOf('/')) const newPath = `${parentDir}/${newName}` // 更新选中状态中的路径 - onSelect(newPath, false) + onSelect(newPath, false, false) setCurrentName(newName) setCurrentPath(newPath) } catch (err) { - console.error('[AttachedDirItem] 重命名失败:', err) + console.error('[WorkspaceDirItem] 重命名失败:', err) } setIsRenaming(false) } @@ -714,8 +863,20 @@ function AttachedDirItem({ entry, depth, selectedPaths, onSelect, refreshVersion const newPath = `${result.path}/${currentName}` setCurrentPath(newPath) } catch (err) { - console.error('[AttachedDirItem] 移动失败:', err) + console.error('[WorkspaceDirItem] 移动失败:', err) + } + } + + // 删除文件/目录 + const handleDelete = async (): Promise => { + try { + await window.electronAPI.deleteAttachedFile(currentPath) + // 通知父组件刷新(通过选择空路径触发) + onSelect('', false, false) + } catch (err) { + console.error('[WorkspaceDirItem] 删除失败:', err) } + setShowDeleteDialog(false) } const paddingLeft = 8 + depth * 16 @@ -816,6 +977,13 @@ function AttachedDirItem({ entry, depth, selectedPaths, onSelect, refreshVersion 移动到... + setShowDeleteDialog(true)} + > + + 删除 +
@@ -830,8 +998,37 @@ function AttachedDirItem({ entry, depth, selectedPaths, onSelect, refreshVersion
)} {expanded && children.map((child) => ( - + ))} + + {/* 删除确认对话框 */} + + + + 确认删除 + + 确定要删除 {currentName} 吗? + {entry.isDirectory && '(包含所有子文件)'} + 此操作不可撤销。 + + + + setShowDeleteDialog(false)}>取消 + + 删除 + + + + ) } diff --git a/apps/electron/src/renderer/components/ai-elements/rich-text-input.tsx b/apps/electron/src/renderer/components/ai-elements/rich-text-input.tsx index 745755a2..a6305136 100644 --- a/apps/electron/src/renderer/components/ai-elements/rich-text-input.tsx +++ b/apps/electron/src/renderer/components/ai-elements/rich-text-input.tsx @@ -184,12 +184,12 @@ interface RichTextInputProps { autoFocusTrigger?: string | null /** 是否支持手动折叠(内容较长时显示折叠按钮) */ collapsible?: boolean - /** 工作区根路径(启用 @ 引用文件功能时需要) */ - workspacePath?: string | null - /** 工作区 slug(启用 / Skill 和 # MCP 功能时需要) */ + /** 工作区 ID(启用 @ 引用文件功能时需要) */ + workspaceId?: string | null + /** 会话 ID(启用 @ 引用文件功能时需要) */ + sessionId?: string | null + /** 工作区 slug(启用 / Skill、# MCP 和 @ 引用文件功能时需要) */ workspaceSlug?: string | null - /** 附加目录路径列表(@ 引用时一并搜索) */ - attachedDirs?: string[] className?: string } @@ -210,9 +210,9 @@ export function RichTextInput({ disabled = false, autoFocusTrigger, collapsible = false, - workspacePath, + workspaceId, + sessionId, workspaceSlug, - attachedDirs = [], }: RichTextInputProps): React.ReactElement { const [isExpanded, setIsExpanded] = useState(false) // 手动折叠状态:用户主动折叠输入框 @@ -229,22 +229,22 @@ export function RichTextInput({ onPasteFilesRef.current = onPasteFiles // Mention 活跃状态(阻止 Enter 发送消息) const mentionActiveRef = useRef(false) - // 工作区路径引用(给 Suggestion 使用) - const workspacePathRef = useRef(workspacePath ?? null) - workspacePathRef.current = workspacePath ?? null - // 附加目录路径引用(给 Suggestion 使用) - const attachedDirsRef = useRef(attachedDirs) - attachedDirsRef.current = attachedDirs - // 工作区 slug 引用(给 Skill/MCP Suggestion 使用) + // 工作区 ID 引用(给文件 Mention Suggestion 使用) + const workspaceIdRef = useRef(workspaceId ?? null) + workspaceIdRef.current = workspaceId ?? null + // 会话 ID 引用(给文件 Mention Suggestion 使用) + const sessionIdRef = useRef(sessionId ?? null) + sessionIdRef.current = sessionId ?? null + // 工作区 slug 引用(给 Skill/MCP/文件 Mention Suggestion 使用) const workspaceSlugRef = useRef(workspaceSlug ?? null) workspaceSlugRef.current = workspaceSlug ?? null - // 是否启用 Mention 功能(需要工作区路径或 slug) - const hasMentionSupport = !!(workspacePath || workspaceSlug) + // 是否启用 Mention 功能(需要工作区信息) + const hasMentionSupport = !!(workspaceId && sessionId) || !!workspaceSlug - // Mention Suggestion 配置(稳定引用,不随 workspacePath 变化重建) + // Mention Suggestion 配置(稳定引用) const mentionSuggestion = useMemo( - () => createFileMentionSuggestion(workspacePathRef, mentionActiveRef, attachedDirsRef), + () => createFileMentionSuggestion(workspaceIdRef, sessionIdRef, workspaceSlugRef, mentionActiveRef), [], ) diff --git a/apps/electron/src/renderer/components/app-shell/LeftSidebar.tsx b/apps/electron/src/renderer/components/app-shell/LeftSidebar.tsx index 2f1da5a6..21d656a1 100644 --- a/apps/electron/src/renderer/components/app-shell/LeftSidebar.tsx +++ b/apps/electron/src/renderer/components/app-shell/LeftSidebar.tsx @@ -38,6 +38,7 @@ import { workspaceCapabilitiesVersionAtom, agentSidePanelOpenMapAtom, agentSidePanelTabMapAtom, + agentSessionDropZoneDismissedAtom, } from '@/atoms/agent-atoms' import { tabsAtom, @@ -191,6 +192,7 @@ export function LeftSidebar({ width }: LeftSidebarProps): React.ReactElement { const setConvPromptId = useSetAtom(conversationPromptIdAtom) const setAgentSidePanelOpen = useSetAtom(agentSidePanelOpenMapAtom) const setAgentSidePanelTab = useSetAtom(agentSidePanelTabMapAtom) + const setAgentSessionDropZoneDismissed = useSetAtom(agentSessionDropZoneDismissedAtom) /** 清理 per-conversation/session Map atoms 条目 */ const cleanupMapAtoms = React.useCallback((id: string) => { @@ -207,7 +209,8 @@ export function LeftSidebar({ width }: LeftSidebarProps): React.ReactElement { setConvPromptId(deleteKey) setAgentSidePanelOpen(deleteKey) setAgentSidePanelTab(deleteKey) - }, [setConvModels, setConvContextLength, setConvThinking, setConvParallel, setConvPromptId, setAgentSidePanelOpen, setAgentSidePanelTab]) + setAgentSessionDropZoneDismissed(deleteKey) + }, [setConvModels, setConvContextLength, setConvThinking, setConvParallel, setConvPromptId, setAgentSidePanelOpen, setAgentSidePanelTab, setAgentSessionDropZoneDismissed]) const currentWorkspaceSlug = React.useMemo(() => { if (!currentWorkspaceId) return null @@ -520,12 +523,25 @@ export function LeftSidebar({ width }: LeftSidebarProps): React.ReactElement { /> ) - // ===== 折叠状态:精简图标视图 ===== - if (sidebarCollapsed) { - return ( + // ===== 侧边栏:折叠/展开两种状态切换,展开时无固定留白栏 ===== + return ( +
+ {/* 折叠状态:仅显示 48px 图标栏 */}
{/* 顶部留空,避开 macOS 红绿灯 */}
@@ -587,245 +603,252 @@ export function LeftSidebar({ width }: LeftSidebarProps): React.ReactElement { 设置
- - {deleteDialog} - {moveDialog}
- ) - } - // ===== 展开状态:完整侧边栏 ===== - return ( -
- {/* 顶部留空,避开 macOS 红绿灯 */} -
- {/* 模式切换器 + 折叠按钮 */} -
-
- + {/* 展开状态:完整 280px 侧边栏,从左侧滑入 */} +
+ {/* 内容区域 */} +
+ {/* 顶部留空,避开 macOS 红绿灯 */} +
+ {/* 模式切换器 + 折叠按钮 */} +
+
+ +
+ + + + + 收起侧边栏 +
- - - - - 收起侧边栏 - -
-
- - {/* Agent 模式:工作区选择器 */} - {mode === 'agent' && ( -
-
- )} - - {/* 新对话/新会话按钮 */} -
- -
- - {/* Chat 模式:导航菜单(置顶区域) */} - {mode === 'chat' && ( -
- } - label="置顶对话" - suffix={ - pinnedConversations.length > 0 ? ( - pinnedExpanded - ? - : - ) : undefined - } - onClick={() => handleItemClick('pinned')} - /> + + {/* Agent 模式:工作区选择器 */} + {mode === 'agent' && ( +
+ +
+ )} + + {/* 新对话/新会话按钮 */} +
+
- )} - - {/* Chat 模式:置顶对话区域 */} - {mode === 'chat' && pinnedExpanded && pinnedConversations.length > 0 && ( -
-
- {pinnedConversations.map((conv) => ( - handleSelectConversation(conv.id, conv.title)} - onRequestDelete={() => handleRequestDelete(conv.id)} - onRename={handleRename} - onTogglePin={handleTogglePin} - onMouseEnter={() => setHoveredId(conv.id)} - onMouseLeave={() => setHoveredId(null)} - /> - ))} + + {/* Chat 模式:导航菜单(置顶区域) */} + {mode === 'chat' && ( +
+ } + label="置顶对话" + suffix={ + pinnedConversations.length > 0 ? ( + pinnedExpanded + ? + : + ) : undefined + } + onClick={() => handleItemClick('pinned')} + /> +
+ )} + + {/* Chat 模式:置顶对话区域 */} + {mode === 'chat' && pinnedExpanded && pinnedConversations.length > 0 && ( +
+
+ {pinnedConversations.map((conv) => ( + handleSelectConversation(conv.id, conv.title)} + onRequestDelete={() => handleRequestDelete(conv.id)} + onRename={handleRename} + onTogglePin={handleTogglePin} + onMouseEnter={() => setHoveredId(conv.id)} + onMouseLeave={() => setHoveredId(null)} + /> + ))} +
+
+ )} + + {/* Agent 模式:导航菜单(置顶区域) */} + {mode === 'agent' && ( +
+ } + label="置顶会话" + suffix={ + pinnedAgentSessions.length > 0 ? ( + pinnedAgentExpanded + ? + : + ) : undefined + } + onClick={() => setPinnedAgentExpanded((prev) => !prev)} + /> +
+ )} + + {/* Agent 模式:置顶会话区域 */} + {mode === 'agent' && pinnedAgentExpanded && pinnedAgentSessions.length > 0 && ( +
+
+ {pinnedAgentSessions.map((session) => ( + handleSelectAgentSession(session.id, session.title)} + onRequestDelete={() => handleRequestDelete(session.id)} + onRequestMove={() => setMoveTargetId(session.id)} + onRename={handleAgentRename} + onTogglePin={handleTogglePinAgent} + onMouseEnter={() => setHoveredId(session.id)} + onMouseLeave={() => setHoveredId(null)} + /> + ))} +
+ )} + + {/* 列表区域:根据模式切换 */} +
+ {mode === 'chat' ? ( + /* Chat 模式:对话按日期分组 */ + conversationGroups.map((group) => ( +
+
+ {group.label} +
+
+ {group.items.map((conv) => ( + handleSelectConversation(conv.id, conv.title)} + onRequestDelete={() => handleRequestDelete(conv.id)} + onRename={handleRename} + onTogglePin={handleTogglePin} + onMouseEnter={() => setHoveredId(conv.id)} + onMouseLeave={() => setHoveredId(null)} + /> + ))} +
+
+ )) + ) : ( + /* Agent 模式:Agent 会话按日期分组 */ + agentSessionGroups.map((group) => ( +
+
+ {group.label} +
+
+ {group.items.map((session) => ( + handleSelectAgentSession(session.id, session.title)} + onRequestDelete={() => handleRequestDelete(session.id)} + onRequestMove={() => setMoveTargetId(session.id)} + onRename={handleAgentRename} + onTogglePin={handleTogglePinAgent} + onMouseEnter={() => setHoveredId(session.id)} + onMouseLeave={() => setHoveredId(null)} + /> + ))} +
+
+ )) + )}
- )} - - {/* Agent 模式:导航菜单(置顶区域) */} - {mode === 'agent' && ( -
+ + {/* Agent 模式:工作区能力指示器 */} + {mode === 'agent' && capabilities && ( +
+ + + + + 点击配置 MCP 与 Skills + +
+ )} + + {/* 底部设置 */} +
} - label="置顶会话" + icon={} + label="设置" + active={activeItem === 'settings'} + onClick={() => handleItemClick('settings')} suffix={ - pinnedAgentSessions.length > 0 ? ( - pinnedAgentExpanded - ? - : + (hasUpdate || hasEnvironmentIssues) ? ( + ) : undefined } - onClick={() => setPinnedAgentExpanded((prev) => !prev)} />
- )} - - {/* Agent 模式:置顶会话区域 */} - {mode === 'agent' && pinnedAgentExpanded && pinnedAgentSessions.length > 0 && ( -
-
- {pinnedAgentSessions.map((session) => ( - handleSelectAgentSession(session.id, session.title)} - onRequestDelete={() => handleRequestDelete(session.id)} - onRequestMove={() => setMoveTargetId(session.id)} - onRename={handleAgentRename} - onTogglePin={handleTogglePinAgent} - onMouseEnter={() => setHoveredId(session.id)} - onMouseLeave={() => setHoveredId(null)} - /> - ))} -
-
- )} - - {/* 列表区域:根据模式切换 */} -
- {mode === 'chat' ? ( - /* Chat 模式:对话按日期分组 */ - conversationGroups.map((group) => ( -
-
- {group.label} -
-
- {group.items.map((conv) => ( - handleSelectConversation(conv.id, conv.title)} - onRequestDelete={() => handleRequestDelete(conv.id)} - onRename={handleRename} - onTogglePin={handleTogglePin} - onMouseEnter={() => setHoveredId(conv.id)} - onMouseLeave={() => setHoveredId(null)} - /> - ))} -
-
- )) - ) : ( - /* Agent 模式:Agent 会话按日期分组 */ - agentSessionGroups.map((group) => ( -
-
- {group.label} -
-
- {group.items.map((session) => ( - handleSelectAgentSession(session.id, session.title)} - onRequestDelete={() => handleRequestDelete(session.id)} - onRequestMove={() => setMoveTargetId(session.id)} - onRename={handleAgentRename} - onTogglePin={handleTogglePinAgent} - onMouseEnter={() => setHoveredId(session.id)} - onMouseLeave={() => setHoveredId(null)} - /> - ))} -
-
- )) - )} -
- - {/* Agent 模式:工作区能力指示器 */} - {mode === 'agent' && capabilities && ( -
- - - - - 点击配置 MCP 与 Skills - +
- )} - - {/* 底部设置 */} -
- } - label="设置" - active={activeItem === 'settings'} - onClick={() => handleItemClick('settings')} - suffix={ - (hasUpdate || hasEnvironmentIssues) ? ( - - ) : undefined - } - />
{deleteDialog} diff --git a/apps/electron/src/renderer/components/file-browser/FileBrowser.tsx b/apps/electron/src/renderer/components/file-browser/FileBrowser.tsx index 62d6c58e..b6265276 100644 --- a/apps/electron/src/renderer/components/file-browser/FileBrowser.tsx +++ b/apps/electron/src/renderer/components/file-browser/FileBrowser.tsx @@ -221,11 +221,7 @@ export function FileBrowser({ rootPath, hideToolbar, embedded }: FileBrowserProp {error && (
{error}
)} - {!error && entries.length === 0 && !loading && ( -
- 目录为空 -
- )} + {/* 空目录时不显示提示,由外部拖拽区域处理 */} {entries.map((entry) => ( + {fileTree} +
+ ) : ( {fileTree} diff --git a/apps/electron/src/renderer/components/file-browser/FileDropZone.tsx b/apps/electron/src/renderer/components/file-browser/FileDropZone.tsx index c89f8b61..b3a33e88 100644 --- a/apps/electron/src/renderer/components/file-browser/FileDropZone.tsx +++ b/apps/electron/src/renderer/components/file-browser/FileDropZone.tsx @@ -1,15 +1,13 @@ /** * FileDropZone — 文件拖拽上传区域 * - * 引导用户通过拖拽或点击将文件添加到 Agent 会话目录或工作区文件目录。 + * 支持拖拽文件/文件夹到目标目录。 * 文件上传后直接保存到目标目录,FileBrowser 通过版本号自动刷新。 */ import * as React from 'react' import { toast } from 'sonner' -import { Upload, File, FolderPlus, Loader2 } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { Upload, Loader2, FolderOpen } from 'lucide-react' import { cn } from '@/lib/utils' import { fileToBase64 } from '@/lib/file-utils' @@ -22,11 +20,30 @@ interface FileDropZoneProps { target?: 'session' | 'workspace' /** 上传成功后的回调(触发文件浏览器刷新) */ onFilesUploaded: () => void - /** 附加文件夹回调 */ - onAttachFolder?: () => void + /** 文件夹关联成功后的回调(用于更新界面状态) */ + onFoldersAttached?: (updatedDirs: string[]) => void + /** 自定义类名 */ + className?: string + /** 隐藏 UI(仅保留拖拽功能) */ + hideUI?: boolean + /** 子元素 */ + children?: React.ReactNode + /** 是否为空目录(为空时显示拖拽UI,有内容时隐藏UI但保留拖拽功能) */ + isEmpty?: boolean } -export function FileDropZone({ workspaceSlug, sessionId, target = 'session', onFilesUploaded, onAttachFolder }: FileDropZoneProps): React.ReactElement { +export function FileDropZone({ + workspaceSlug, + sessionId, + target = 'session', + onFilesUploaded, + onFoldersAttached, + className, + hideUI, + children, + isEmpty, +}: FileDropZoneProps): React.ReactElement { + console.log('[FileDropZone] Render, isEmpty:', isEmpty, 'children:', !!children, 'target:', target) const [isDragOver, setIsDragOver] = React.useState(false) const [isUploading, setIsUploading] = React.useState(false) @@ -49,12 +66,14 @@ export function FileDropZone({ workspaceSlug, sessionId, target = 'session', onF workspaceSlug, files: fileEntries, }) - } else { + } else if (sessionId) { await window.electronAPI.saveFilesToAgentSession({ workspaceSlug, - sessionId: sessionId!, + sessionId, files: fileEntries, }) + } else { + throw new Error('sessionId is required for session target') } onFilesUploaded() @@ -88,29 +107,61 @@ export function FileDropZone({ workspaceSlug, sessionId, target = 'session', onF const items = Array.from(e.dataTransfer.items) const regularFiles: globalThis.File[] = [] - let hasFolders = false + let folderPaths: string[] = [] for (const item of items) { if (item.kind !== 'file') continue const entry = item.webkitGetAsEntry?.() if (entry?.isDirectory) { - hasFolders = true + // 对于工作区模式,获取文件夹路径 + if (isWorkspace && onFoldersAttached) { + // 使用 Electron webUtils.getPathForFile 安全获取路径(contextIsolation 兼容) + const file = item.getAsFile() + if (file) { + const path = window.electronAPI.getFilePath(file) + if (path) { + folderPaths.push(path) + } + } + } } else { const file = item.getAsFile() if (file) regularFiles.push(file) } } - if (hasFolders) { - toast.info('不支持拖拽文件夹', { description: '请使用「附加文件夹」按钮' }) + // 工作区模式:拖拽文件夹时自动关联 + if (isWorkspace && folderPaths.length > 0 && onFoldersAttached) { + let updatedDirs: string[] = [] + for (const folderPath of folderPaths) { + try { + // 从路径提取目录名 + const folderName = folderPath.split('/').pop() ?? '文件夹' + // 调用关联文件夹 API,返回更新后的目录列表 + updatedDirs = await window.electronAPI.attachWorkspaceDirectory({ + workspaceSlug, + directoryPath: folderPath, + }) + toast.success(`已关联文件夹: ${folderName}`) + } catch (error) { + console.error('[FileDropZone] 关联文件夹失败:', error) + toast.error('关联文件夹失败') + } + } + // 通知父组件更新目录列表状态 + if (onFoldersAttached && updatedDirs.length > 0) { + onFoldersAttached(updatedDirs) + } + onFilesUploaded() + return } if (regularFiles.length > 0) { await saveFiles(regularFiles) } - }, [saveFiles]) + }, [saveFiles, isWorkspace, workspaceSlug, onFoldersAttached, onFilesUploaded]) - // ===== 按钮点击处理 ===== + // ===== 按钮点击处理(选择文件)===== const handleSelectFiles = React.useCallback(async (): Promise => { try { @@ -128,12 +179,14 @@ export function FileDropZone({ workspaceSlug, sessionId, target = 'session', onF workspaceSlug, files: fileEntries, }) - } else { + } else if (sessionId) { await window.electronAPI.saveFilesToAgentSession({ workspaceSlug, - sessionId: sessionId!, + sessionId, files: fileEntries, }) + } else { + throw new Error('sessionId is required for session target') } onFilesUploaded() @@ -146,12 +199,88 @@ export function FileDropZone({ workspaceSlug, sessionId, target = 'session', onF } }, [workspaceSlug, sessionId, isWorkspace, onFilesUploaded]) + // 拖拽遮罩层(拖拽时显示) + const dragOverlay = isDragOver && ( +
+ + 释放以关联文件/文件夹 +
+ ) + + // 上传中遮罩层 + const uploadingOverlay = isUploading && ( +
+ + 正在上传... +
+ ) + + // 有子元素时的渲染 + if (children) { + // 非空时(isEmpty=false 或 hideUI=true):隐藏拖拽 UI 但保留功能 + if (isEmpty === false || hideUI) { + return ( +
+ {children} + {dragOverlay} + {uploadingOverlay} +
+ ) + } + + // 空目录时:只显示拖拽 UI,不渲染 children(避免背景遮挡) + return ( +
+
+ {isWorkspace ? ( + + ) : ( + + )} +

+ 拖拽文件或文件夹到此处 +
+ + {isWorkspace ? '工作区内所有会话可访问' : '供 Agent 读取和处理'} + +

+ {dragOverlay} + {uploadingOverlay} +
+
+ ) + } + + // 无子元素时:默认显示拖拽区域 UI return ( -
+
- {isUploading ? ( - <> - - 正在上传... - + {isWorkspace ? ( + ) : ( - <> - -

- 拖拽文件到此处 -
- - {isWorkspace ? '工作区内所有会话可访问' : '供 Agent 读取和处理'} - -

-
- - - - - -

{isWorkspace ? '添加文件到工作区文件目录' : '将文件放入 Agent 工作文件夹'}

-
-
- {onAttachFolder && ( - - - - - -

{isWorkspace ? '附加文件夹供工作区所有会话访问' : '告知 Agent 你想处理的文件夹'}

-
-
- )} -
- + )} +

+ 拖拽文件或文件夹到此处 +
+ + {isWorkspace ? '工作区内所有会话可访问' : '供 Agent 读取和处理'} + +

+ {dragOverlay} + {uploadingOverlay}
) diff --git a/apps/electron/src/renderer/components/file-browser/FileMentionList.tsx b/apps/electron/src/renderer/components/file-browser/FileMentionList.tsx index 29aeb141..92051178 100644 --- a/apps/electron/src/renderer/components/file-browser/FileMentionList.tsx +++ b/apps/electron/src/renderer/components/file-browser/FileMentionList.tsx @@ -1,12 +1,14 @@ /** * FileMentionList — @ 引用文件下拉列表 * - * 显示文件搜索结果,支持键盘导航(上/下/Enter/Escape)。 + * 显示文件搜索结果,按"本会话文件"和"工作区文件"分组, + * 工作区文件进一步按文件夹(上传文件、voc、generated等)分组。 + * 支持键盘导航(上/下/Enter/Escape)。 * 通过 React.useImperativeHandle 暴露 onKeyDown 给 TipTap Suggestion。 */ import * as React from 'react' -import { Folder, FileText } from 'lucide-react' +import { Folder, FileText, ChevronRight } from 'lucide-react' import { cn } from '@/lib/utils' import type { FileIndexEntry } from '@proma/shared' @@ -20,37 +22,107 @@ export interface FileMentionRef { onKeyDown: (props: { event: KeyboardEvent }) => boolean } +// 文件夹分组 +interface FolderGroup { + name: string + path: string + items: FileIndexEntry[] +} + +// 一级分组(本会话/工作区) +interface SourceGroup { + source: 'session' | 'workspace' + name: string + // 本会话文件的直接子项 + items: FileIndexEntry[] + // 工作区文件的文件夹分组 + folders: FolderGroup[] +} + export const FileMentionList = React.forwardRef( function FileMentionList({ items, selectedIndex, onSelect }, ref) { - const [localIndex, setLocalIndex] = React.useState(selectedIndex) const containerRef = React.useRef(null) - // items 变化时重置选中索引 - React.useEffect(() => { - setLocalIndex(0) + // 构建分组数据结构 + const groupedData = React.useMemo(() => { + // 按 source 分组 + const sessionItems = items.filter((item) => item.source === 'session') + const workspaceItems = items.filter((item) => item.source === 'workspace') + + // 工作区文件按 folder 分组 + const folderMap = new Map() + workspaceItems.forEach((item) => { + const folderName = item.folder || '其他' + const folderPath = item.folderPath || '' + if (!folderMap.has(folderName)) { + folderMap.set(folderName, { name: folderName, path: folderPath, items: [] }) + } + folderMap.get(folderName)!.items.push(item) + }) + + const result: SourceGroup[] = [] + if (sessionItems.length > 0) { + result.push({ + source: 'session', + name: '本会话文件', + items: sessionItems, + folders: [], + }) + } + + if (workspaceItems.length > 0) { + result.push({ + source: 'workspace', + name: '工作区文件', + items: [], + folders: Array.from(folderMap.values()), + }) + } + + return result }, [items]) + // 构建扁平化列表用于键盘导航(按显示顺序) + const flatItems = React.useMemo(() => { + const flat: FileIndexEntry[] = [] + groupedData.forEach((group) => { + if (group.source === 'session') { + flat.push(...group.items) + } else { + group.folders.forEach((folder) => { + flat.push(...folder.items) + }) + } + }) + return flat + }, [groupedData]) + // 滚动选中项到可见区域 React.useEffect(() => { const container = containerRef.current if (!container) return - const item = container.children[localIndex] as HTMLElement | undefined + const buttons = container.querySelectorAll('button[data-item-index]') + const item = buttons[selectedIndex] as HTMLElement | undefined item?.scrollIntoView({ block: 'nearest' }) - }, [localIndex]) + }, [selectedIndex]) // 暴露键盘处理给 TipTap React.useImperativeHandle(ref, () => ({ onKeyDown: ({ event }) => { if (event.key === 'ArrowUp') { - setLocalIndex((prev) => (prev <= 0 ? items.length - 1 : prev - 1)) + const newIndex = selectedIndex <= 0 ? flatItems.length - 1 : selectedIndex - 1 + const item = flatItems[newIndex] + if (item) onSelect(item) return true } if (event.key === 'ArrowDown') { - setLocalIndex((prev) => (prev >= items.length - 1 ? 0 : prev + 1)) + const newIndex = selectedIndex >= flatItems.length - 1 ? 0 : selectedIndex + 1 + const item = flatItems[newIndex] + if (item) onSelect(item) return true } if (event.key === 'Enter') { - const item = items[localIndex] + const item = flatItems[selectedIndex] if (item) onSelect(item) return true } @@ -70,34 +142,94 @@ export const FileMentionList = React.forwardRef { + // 基础缩进 12px,每层 depth 增加 12px + const depth = item.depth ?? 0 + const paddingLeft = 12 + depth * 12 // 基础 12px + 层级缩进 + + return ( + + ) + } + + // 计算全局索引 + let currentIndex = 0 + const getNextIndex = () => currentIndex++ + return (
- {items.map((item, index) => ( - +
))}
) diff --git a/apps/electron/src/renderer/components/file-browser/file-mention-suggestion.tsx b/apps/electron/src/renderer/components/file-browser/file-mention-suggestion.tsx index eb1be1af..cccb1a53 100644 --- a/apps/electron/src/renderer/components/file-browser/file-mention-suggestion.tsx +++ b/apps/electron/src/renderer/components/file-browser/file-mention-suggestion.tsx @@ -2,7 +2,9 @@ * FileMentionSuggestion — TipTap Mention Suggestion 配置 * * 工厂函数,创建用于 @ 引用文件的 TipTap Suggestion 配置。 - * 输入 @ 后异步搜索工作区文件,弹出 FileMentionList 浮动列表。 + * + * 设计原则:所有数据直接通过 IPC 获取,不依赖外部状态。 + * 输入 @ 后,递归扫描所有目录显示在 FileMentionList 中。 */ import type React from 'react' @@ -10,40 +12,147 @@ import { ReactRenderer } from '@tiptap/react' import type { SuggestionOptions } from '@tiptap/suggestion' import { FileMentionList } from './FileMentionList' import type { FileMentionRef } from './FileMentionList' -import type { FileIndexEntry } from '@proma/shared' +import type { FileIndexEntry, FileEntry } from '@proma/shared' import { createMentionPopup, positionPopup } from '@/components/agent/mention-popup-utils' +/** + * 从路径中提取文件夹名称 + */ +function getFolderName(path: string): string { + const parts = path.split('/').filter(Boolean) + return parts[parts.length - 1] || path +} + +/** + * 递归扫描目录,限制深度 + */ +async function scanDirectory( + dirPath: string, + folderName: string, + queryLower: string, + depth: number = 0, + maxDepth: number = 3, + listFn: (path: string) => Promise +): Promise { + if (depth > maxDepth) return [] + + const items = await listFn(dirPath).catch(() => []) + const result: FileIndexEntry[] = [] + + for (const item of items) { + // 过滤匹配查询 + if (queryLower && !item.name.toLowerCase().includes(queryLower)) { + // 如果当前项不匹配,但如果是目录,仍可能子项匹配,继续递归 + if (!item.isDirectory) continue + } + + result.push({ + name: item.name, + path: item.path, + type: (item.isDirectory ? 'dir' : 'file') as 'file' | 'dir', + source: 'workspace' as const, + folder: folderName, + folderPath: dirPath, + depth, + }) + + // 递归扫描子目录 + if (item.isDirectory) { + const subItems = await scanDirectory(item.path, folderName, queryLower, depth + 1, maxDepth, listFn) + result.push(...subItems) + } + } + + return result +} + /** * 创建文件 @ 引用的 Suggestion 配置 - * - * @param workspacePathRef 当前工作区根路径引用 + * + * @param workspaceIdRef 当前工作区 ID + * @param sessionIdRef 当前会话 ID + * @param workspaceSlugRef 当前工作区 slug * @param mentionActiveRef 是否正在 mention 模式(用于阻止 Enter 发送消息) - * @param attachedDirsRef 附加目录路径列表引用(搜索时一并扫描) */ export function createFileMentionSuggestion( - workspacePathRef: React.RefObject, + workspaceIdRef: React.RefObject, + sessionIdRef: React.RefObject, + workspaceSlugRef: React.RefObject, mentionActiveRef: React.MutableRefObject, - attachedDirsRef?: React.RefObject, ): Omit, 'editor'> { return { char: '@', allowSpaces: false, - // 异步搜索文件 + // 异步搜索文件(本会话文件 + 工作区文件) items: async ({ query }): Promise => { - const wsPath = workspacePathRef.current - if (!wsPath) return [] + const workspaceId = workspaceIdRef.current + const sessionId = sessionIdRef.current + const workspaceSlug = workspaceSlugRef.current + const queryLower = (query ?? '').toLowerCase() + + // 如果没有工作区和会话信息,返回空 + if (!workspaceId || !sessionId) return [] try { - const additionalPaths = attachedDirsRef?.current ?? [] - const result = await window.electronAPI.searchWorkspaceFiles( - wsPath, - query ?? '', - 20, - additionalPaths.length > 0 ? additionalPaths : undefined, - ) - return result.entries - } catch { + // 并行获取所有路径信息 + const [ + sessionPathResult, + workspaceFilesPathResult, + attachedDirsResult, + ] = await Promise.all([ + window.electronAPI.getAgentSessionPath(workspaceId, sessionId).catch(() => null), + workspaceSlug ? window.electronAPI.getWorkspaceFilesPath(workspaceSlug).catch(() => null) : Promise.resolve(null), + workspaceSlug ? window.electronAPI.getWorkspaceDirectories(workspaceSlug).catch(() => []) : Promise.resolve([]), + ]) + + const allItems: FileIndexEntry[] = [] + + // 1. 搜索本会话文件(递归,限制深度3) + if (sessionPathResult) { + const sessionFiles = await scanDirectory( + sessionPathResult, + '本会话文件', + queryLower, + 0, + 3, + (path) => window.electronAPI.listDirectory(path) + ) + // 标记为 session source + sessionFiles.forEach(item => { item.source = 'session'; item.folder = undefined }) + allItems.push(...sessionFiles) + } + + // 2.1 搜索上传文件目录(递归,限制深度3) + if (workspaceFilesPathResult) { + const uploadFiles = await scanDirectory( + workspaceFilesPathResult, + '上传文件', + queryLower, + 0, + 3, + (path) => window.electronAPI.listDirectory(path) + ) + allItems.push(...uploadFiles) + } + + // 2.2 搜索关联的外部目录(递归,限制深度3) + for (const dir of attachedDirsResult) { + const folderName = getFolderName(dir) + const attachedFiles = await scanDirectory( + dir, + folderName, + queryLower, + 0, + 3, + (path) => window.electronAPI.listAttachedDirectory(path) + ) + allItems.push(...attachedFiles) + } + + return allItems + } catch (error) { + console.error('[FileMention] 搜索文件失败:', error) return [] } }, diff --git a/apps/electron/src/renderer/components/tabs/TabBar.tsx b/apps/electron/src/renderer/components/tabs/TabBar.tsx index e4716c7f..11b43712 100644 --- a/apps/electron/src/renderer/components/tabs/TabBar.tsx +++ b/apps/electron/src/renderer/components/tabs/TabBar.tsx @@ -30,6 +30,7 @@ import { import { agentSidePanelOpenMapAtom, agentSidePanelTabMapAtom, + agentSessionDropZoneDismissedAtom, } from '@/atoms/agent-atoms' import { conversationPromptIdAtom } from '@/atoms/system-prompt-atoms' import { TabBarItem } from './TabBarItem' @@ -50,6 +51,7 @@ export function TabBar(): React.ReactElement { const setConvPromptId = useSetAtom(conversationPromptIdAtom) const setAgentSidePanelOpen = useSetAtom(agentSidePanelOpenMapAtom) const setAgentSidePanelTab = useSetAtom(agentSidePanelTabMapAtom) + const setAgentSessionDropZoneDismissed = useSetAtom(agentSessionDropZoneDismissedAtom) /** 清理关闭标签对应的 per-conversation/session Map atoms 条目 */ const cleanupMapAtoms = React.useCallback((tabId: string) => { @@ -68,7 +70,8 @@ export function TabBar(): React.ReactElement { // Agent per-session atoms setAgentSidePanelOpen(deleteKey) setAgentSidePanelTab(deleteKey) - }, [setConvModels, setConvContextLength, setConvThinking, setConvParallel, setConvPromptId, setAgentSidePanelOpen, setAgentSidePanelTab]) + setAgentSessionDropZoneDismissed(deleteKey) + }, [setConvModels, setConvContextLength, setConvThinking, setConvParallel, setConvPromptId, setAgentSidePanelOpen, setAgentSidePanelTab, setAgentSessionDropZoneDismissed]) // 拖拽状态 const dragState = React.useRef<{ diff --git a/apps/electron/src/renderer/index.html b/apps/electron/src/renderer/index.html index 57d4423f..2323adf0 100644 --- a/apps/electron/src/renderer/index.html +++ b/apps/electron/src/renderer/index.html @@ -3,6 +3,7 @@ + Proma