From c105b585eb757c72bd2b7eba362fcd9c87833608 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Thu, 18 Jun 2026 14:09:00 +0800 Subject: [PATCH 01/14] =?UTF-8?q?refactor(ui):=20=E6=8A=BD=E5=87=BA?= =?UTF-8?q?=E9=80=9A=E7=94=A8=20Modal=20=E5=A3=B3=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E5=B9=B6=E8=BF=81=E7=A7=BB=20ConfirmModal=20/=20RulePreviewMod?= =?UTF-8?q?al?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 common/Modal.tsx 统一模态脚手架:backdrop(点外关闭 + 嵌套防冒泡)、 dialog 容器、可选 header(标题 + 关闭键 icon/text)、modal-body 包裹、可选 footer 区、portal / nested / size 变体。键盘交互仍由调用方自管。 ConfirmModal、RulePreviewModal 改用 Modal 壳,消除手写 backdrop/容器/header 重复。视觉与行为不变。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/common/ConfirmModal.tsx | 51 +++----- .../renderer/src/components/common/Modal.tsx | 114 ++++++++++++++++++ .../chat/components/RulePreviewModal.tsx | 58 ++++----- 3 files changed, 157 insertions(+), 66 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/common/Modal.tsx diff --git a/apps/desktop/src/renderer/src/components/common/ConfirmModal.tsx b/apps/desktop/src/renderer/src/components/common/ConfirmModal.tsx index b93906e..5d823f4 100644 --- a/apps/desktop/src/renderer/src/components/common/ConfirmModal.tsx +++ b/apps/desktop/src/renderer/src/components/common/ConfirmModal.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; -import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; +import { Modal } from './Modal'; interface ConfirmModalProps { title?: string; @@ -14,8 +14,7 @@ interface ConfirmModalProps { } /** - * 通用确认模态框,跟 SettingsModal 共用 .modal-backdrop / .modal 视觉语言。 - * 用 createPortal 渲染到 document.body 避开调用者所在层级(特别是 Monaco view + * 通用确认模态框,基于 Modal 壳。经 portal 渲染到 body 避开调用者层级(特别是 Monaco view * zone 内的 React tree,那一层 z-index 比 modal 低)。 * * 键盘:Esc 取消,Enter 确认(焦点默认在取消按钮,避免误触) @@ -47,32 +46,17 @@ export function ConfirmModal({ return () => window.removeEventListener('keydown', onKey); }, [onCancel, onConfirm]); - return createPortal( -
{ - e.stopPropagation(); - onCancel(); - }} - role="presentation" - > -
e.stopPropagation()} - role="dialog" - aria-modal="true" - aria-labelledby="confirm-modal-title" - > -
-

{resolvedTitle}

-
-
-

{message}

-
-
+ return ( + @@ -83,9 +67,10 @@ export function ConfirmModal({ > {resolvedConfirmLabel} -
-
-
, - document.body, + + } + > +

{message}

+ ); } diff --git a/apps/desktop/src/renderer/src/components/common/Modal.tsx b/apps/desktop/src/renderer/src/components/common/Modal.tsx new file mode 100644 index 0000000..b2d7a56 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/common/Modal.tsx @@ -0,0 +1,114 @@ +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; +import type { CSSProperties, ReactNode } from 'react'; +import { CloseIcon } from './icons'; + +type ModalSize = 'md' | 'sm' | 'confirm'; + +const SIZE_CLASS: Record = { + md: 'modal', + sm: 'modal modal-sm', + confirm: 'modal modal-confirm', +}; + +interface ModalProps { + onClose: () => void; + /** 弹窗尺寸 → 容器类名:md=modal · sm=modal-sm · confirm=modal-confirm */ + size?: ModalSize; + /** 二层嵌套模态:加 modal-backdrop-nested,背景点击 stopPropagation 防冒泡关掉外层模态 */ + nested?: boolean; + /** 经 createPortal 渲染到 body:避开调用者所在层级(如 Monaco view zone 内 z-index 偏低) */ + portal?: boolean; + /** 点背景是否关闭(默认 true) */ + closeOnBackdrop?: boolean; + /** 标题(不传则不渲染 header) */ + title?: ReactNode; + /** 标题元素 id,配合 aria-labelledby */ + titleId?: string; + /** header 右侧关闭按钮样式:图标按钮 / 文案按钮(不传则无) */ + headerClose?: 'icon' | 'text'; + /** modal-body 追加类名(如 confirm-body) */ + bodyClassName?: string; + /** 底部 footer 区内容(作为 modal-body 的兄弟渲染);不传则无 footer 区 */ + footer?: ReactNode; + /** footer 容器类名(默认 modal-footer-bar;确认框用 modal-actions) */ + footerClassName?: string; + /** 容器内联样式(个别弹窗自定宽度用) */ + style?: CSSProperties; + ariaLabel?: string; + ariaLabelledby?: string; + children: ReactNode; +} + +/** + * 通用模态壳:统一 backdrop(点外关闭 + 嵌套防冒泡)、dialog 容器、可选 header(标题 + 关闭键)、 + * modal-body 包裹、可选 footer 区。键盘交互(Esc / Enter)由各调用方按需自管——壳只负责结构与样式语言。 + */ +export function Modal({ + onClose, + size = 'md', + nested = false, + portal = false, + closeOnBackdrop = true, + title, + titleId, + headerClose, + bodyClassName, + footer, + footerClassName = 'modal-footer-bar', + style, + ariaLabel, + ariaLabelledby, + children, +}: ModalProps) { + const { t } = useTranslation(); + const tree = ( +
{ + e.stopPropagation(); + if (closeOnBackdrop) onClose(); + }} + role="presentation" + > +
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label={ariaLabel} + aria-labelledby={ariaLabelledby ?? titleId} + > + {title !== undefined && ( +
+

{title}

+ {headerClose === 'icon' && ( + + )} + {headerClose === 'text' && ( + + )} +
+ )} +
+ {children} +
+ {footer !== undefined &&
{footer}
} +
+
+ ); + return portal ? createPortal(tree, document.body) : tree; +} diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/RulePreviewModal.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/RulePreviewModal.tsx index ce57d3b..4bea768 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/components/RulePreviewModal.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/components/RulePreviewModal.tsx @@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'; import ReactMarkdown from 'react-markdown'; import remarkBreaks from 'remark-breaks'; import remarkGfm from 'remark-gfm'; +import { Modal } from '../../../common/Modal'; import { mermaidComponents } from '../../../common/markdownMermaid'; import { REMOTE_REHYPE_PLUGINS } from '../../../../markdown'; import type { MatchedRule } from '../types'; @@ -15,39 +16,30 @@ export function RulePreviewModal({ }) { const { t } = useTranslation(); return ( -
-
e.stopPropagation()} - role="dialog" - aria-label={t('chatPane.rulePreviewAria')} - > -
-

{t('chatPane.rulePreviewTitle', { id: rule.id })}

- -
-
-
-
{t('chatPane.ruleFilePath')}
-
{rule.filePath}
-
priority
-
{rule.priority}
-
tools
-
{rule.tools.join(', ')}
-
-
- - {rule.instructions} - -
-
+ +
+
{t('chatPane.ruleFilePath')}
+
{rule.filePath}
+
priority
+
{rule.priority}
+
tools
+
{rule.tools.join(', ')}
-
+
+ + {rule.instructions} + +
+ ); } From dbe02f2c3a9de122e1c06c7eec12885e832d0c21 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Thu, 18 Jun 2026 14:18:53 +0800 Subject: [PATCH 02/14] =?UTF-8?q?refactor(settings):=20SettingsModal=20?= =?UTF-8?q?=E6=8B=86=E5=88=86=E4=B8=BA=E5=AE=B9=E5=99=A8/=E5=88=86?= =?UTF-8?q?=E5=8C=BA/=E7=BC=96=E8=BE=91=E5=99=A8/hook/=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1222 行单文件 SettingsModal 按职责拆开(容器 175 行): - hooks/useSettingsDraft —— 草稿 → 整体保存状态机(连接 / LLM / 代理 / 轮询 / 目录 / 语言),暴露各分区所需的语义化 state 与 setter(编辑即标脏) - sections/ —— 九个独立分区(语言 / 连接 / 轮询 / LLM / 代理 / Agent 目录 / 工作目录 / 缓存目录 / 运行环境),各自成文件、可独立维护 - editors/ —— 连接 / 代理 / LLM 编辑器弹窗,统一改用 common/Modal 壳 - elements/UpdateCheckButton —— 「检查更新」小部件 - utils —— formatBytes + 轮询档位(POLLER_TIERS / nearestPollerIdx) 容器只做布局编排 + 装配分区 + 底栏保存。对外接口与界面行为不变。 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 + .../features/settings/SettingsModal.tsx | 1263 ++--------------- .../editors/ConnectionEditorModal.tsx | 63 + .../settings/editors/LlmEditorModal.tsx | 61 + .../settings/editors/ProxyEditorModal.tsx | 165 +++ .../settings/elements/UpdateCheckButton.tsx | 66 + .../settings/hooks/useSettingsDraft.ts | 370 +++++ .../settings/sections/AgentDirSection.tsx | 54 + .../settings/sections/CacheDirSection.tsx | 55 + .../settings/sections/ConnectionsSection.tsx | 89 ++ .../settings/sections/LanguageSection.tsx | 34 + .../features/settings/sections/LlmSection.tsx | 93 ++ .../settings/sections/PollerSection.tsx | 62 + .../settings/sections/ProxySection.tsx | 33 + .../settings/sections/RuntimeSection.tsx | 73 + .../settings/sections/WorkDirSection.tsx | 17 + .../src/components/features/settings/utils.ts | 20 + 17 files changed, 1365 insertions(+), 1155 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/features/settings/editors/ConnectionEditorModal.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/settings/editors/LlmEditorModal.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/settings/editors/ProxyEditorModal.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/settings/elements/UpdateCheckButton.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/settings/hooks/useSettingsDraft.ts create mode 100644 apps/desktop/src/renderer/src/components/features/settings/sections/AgentDirSection.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/settings/sections/CacheDirSection.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/settings/sections/ConnectionsSection.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/settings/sections/LanguageSection.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/settings/sections/LlmSection.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/settings/sections/PollerSection.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/settings/sections/ProxySection.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/settings/sections/RuntimeSection.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/settings/sections/WorkDirSection.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/settings/utils.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1543ed9..34caa4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ - 聊天面板(PR Agent)内部重构:2578 行的单文件 `ChatPane.tsx` 按「容器 / 领域组件 / hooks / 工具方法」分层拆分到 `components/chat/`,状态与生命周期、业务动作、时间线归并各自成 hook,展示组件与工具方法独立成文件。对外接口与界面行为保持不变,仅改善可维护性;token 用量 ↑/↓ 的绿红色值一并从内联样式收进设计令牌与样式类。 - 首启向导内部重构:`OnboardingWizard.tsx` 的四个步骤组件(欢迎 / 平台 / LLM / 完成)拆分到 `steps/` 各自成文件,容器只留向导骨架(步骤指示 + 切换 + 导航)。对外接口与界面行为不变。 - `components/` 目录按职责重组:扁平堆叠的组件归入三类——`common/`(基础公共 UI)、`layout/`(应用骨架)、`features/`(业务领域:pr / diff / comments / drafts / settings / chat / onboarding),顶层只剩这三个桶。纯文件位置调整 + import 路径改写,无逻辑 / 界面变更。 +- 新增通用 `common/Modal` 模态壳(backdrop / header / body / footer / portal / nested / size 变体),统一各处手写的模态脚手架;ConfirmModal、RulePreviewModal 及设置面板的全部弹窗改用之。 +- 设置面板内部重构:1222 行的 `SettingsModal.tsx` 拆分为容器(175 行)+ `useSettingsDraft`(草稿/保存状态机)+ 九个独立 `sections/` 分区 + `editors/`(连接/代理/LLM 编辑器)+ `elements/`(小部件)+ `utils`(formatBytes / 轮询档位),各分区可独立维护。对外接口与界面行为不变。 ### Fixed diff --git a/apps/desktop/src/renderer/src/components/features/settings/SettingsModal.tsx b/apps/desktop/src/renderer/src/components/features/settings/SettingsModal.tsx index f9c50fc..a7521f8 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/SettingsModal.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/SettingsModal.tsx @@ -1,38 +1,20 @@ -import { useEffect, useState, type CSSProperties } from 'react'; import { useTranslation } from 'react-i18next'; -import { - LANGUAGE_OPTIONS, - type AppInfo, - type AppPaths, - type Config, - type LlmProfile, - type SupportedLanguage, - type UpdateCheckResult, -} from '@meebox/shared'; -import { invoke } from '../../../api'; -import i18n, { persistLanguage, resolveUiLanguage } from '../../../i18n'; +import type { AppInfo, AppPaths, Config, SupportedLanguage } from '@meebox/shared'; import { ConfirmModal } from '../../common/ConfirmModal'; -import { - ConnectionForm, - connDraftCanSave, - fromConnDraft, - toConnDraft, - type ConnDraft, -} from './ConnectionForm'; -import { LlmProfileForm, newProfileId, providerLabel, validateProfile } from './LlmProfileForm'; -import { - CloseIcon, - EyeIcon, - EyeOffIcon, - FolderIcon, - GitHubMarkIcon, - IssueIcon, - PencilIcon, - TagIcon, - TrashIcon, -} from '../../common/icons'; -import { LlmProviderIcon } from '../../common/LlmProviderIcon'; -import { PLATFORM_META } from '../../common/PlatformIcon'; +import { Modal } from '../../common/Modal'; +import { useSettingsDraft } from './hooks/useSettingsDraft'; +import { ConnectionEditorModal } from './editors/ConnectionEditorModal'; +import { LlmEditorModal } from './editors/LlmEditorModal'; +import { ProxyEditorModal } from './editors/ProxyEditorModal'; +import { LanguageSection } from './sections/LanguageSection'; +import { ConnectionsSection } from './sections/ConnectionsSection'; +import { PollerSection } from './sections/PollerSection'; +import { LlmSection } from './sections/LlmSection'; +import { ProxySection } from './sections/ProxySection'; +import { AgentDirSection } from './sections/AgentDirSection'; +import { WorkDirSection } from './sections/WorkDirSection'; +import { CacheDirSection } from './sections/CacheDirSection'; +import { RuntimeSection } from './sections/RuntimeSection'; interface SettingsModalProps { info: AppInfo; @@ -52,18 +34,10 @@ interface SettingsModalProps { onClose: () => void; } -// 轮询间隔档位(秒):低值细(30s 一档)、高值粗(分钟级),梯度放大。滑块拖的是 -// 档位索引而非秒数,从而实现非线性步长 + 离散刻度。 -const POLLER_TIERS = [60, 90, 120, 180, 300, 600, 900]; -/** 取最接近给定秒数的档位索引(配置值不在档位上时就近吸附) */ -function nearestPollerIdx(seconds: number): number { - let best = 0; - for (let i = 1; i < POLLER_TIERS.length; i++) { - if (Math.abs(POLLER_TIERS[i]! - seconds) < Math.abs(POLLER_TIERS[best]! - seconds)) best = i; - } - return best; -} - +/** + * 设置面板(容器):布局编排 + 装配各分区。草稿 / 保存状态机归 useSettingsDraft, + * 各设置分区拆到 sections/,连接 / 代理 / LLM 编辑器拆到 editors/,通用模态壳用 common/Modal。 + */ export function SettingsModal({ info, paths, @@ -75,1148 +49,127 @@ export function SettingsModal({ onClose, }: SettingsModalProps) { const { t } = useTranslation(); - const [opening, setOpening] = useState(false); - const [openError, setOpenError] = useState(null); - - // 草稿 → 整体保存:所有编辑只改本地 state,点底栏"保存"才整体写盘 + 生效 - const [reposDirInput, setReposDirInput] = useState(config.workspace.repos_dir); - // Agent 其余字段(max_steps / summary_max_chars / autopilot)在 UI 不编辑,仅持有以便保存时 - // 原样回传、不被覆盖成默认值;只有目录经 agentDirInput 可编辑。 - const [agent] = useState(config.agent); - const [agentDirInput, setAgentDirInput] = useState(config.agent.dir); - const [pollerInput, setPollerInput] = useState(String(config.poller.interval_seconds)); - const [llm, setLlm] = useState(config.llm); - const [llmEditor, setLlmEditor] = useState<{ mode: 'add' | 'edit'; draft: LlmProfile } | null>( - null, - ); - const [proxy, setProxy] = useState(config.proxy); - // 代理在独立模态框里编辑:null=关闭,非 null=正在编辑的草稿;保存回 proxy,底栏「保存」才写盘。 - const [proxyEditor, setProxyEditor] = useState(null); - - // 保存基线:保存成功后更新,用于 changed 判定(禁用保存按钮) - const [base, setBase] = useState(() => ({ - reposDir: config.workspace.repos_dir, - agentDir: config.agent.dir, - poller: config.poller.interval_seconds, - llm: config.llm, - proxy: config.proxy, - connections: config.connections, - activeConnId: config.active_connection_id, - })); - const [saving, setSaving] = useState(false); - const [saved, setSaved] = useState(false); - const [saveError, setSaveError] = useState(null); - - // UI 语言:即时生效项(不走全局保存)。下拉值取「当前生效语言」(config.language 经 - // resolveUiLanguage 解析,空配置 → OS 偏好)。选择后立即写盘 + 主进程/渲染层同步切换。 - const [language, setLanguage] = useState(() => - resolveUiLanguage(config.language), - ); - const handleLanguageChange = (next: SupportedLanguage): void => { - if (next === language) return; - setLanguage(next); - void i18n.changeLanguage(next); // 渲染层实时切换 - persistLanguage(next); // localStorage 缓存,下次启动同步命中 - onLanguageChange?.(next); // 同步父级 boot.config.language - invoke('config:setLanguage', { language: next }).catch((e: unknown) => { - // 写盘 / 主进程切换失败不回滚 UI(已切),仅提示;下次启动按 localStorage 兜底 - setSaveError(e instanceof Error ? e.message : String(e)); - }); - }; - - const [totalBytes, setTotalBytes] = useState(null); - - useEffect(() => { - invoke('repo:getTotalSize', undefined) - .then((r) => setTotalBytes(r.totalBytes)) - .catch(() => setTotalBytes(0)); - }, []); - - const openConfigFile = async (): Promise => { - setOpening(true); - setOpenError(null); - try { - await invoke('app:openConfigFile', undefined); - } catch (e) { - setOpenError(e instanceof Error ? e.message : String(e)); - } finally { - setOpening(false); - } - }; - - const pollerIdx = nearestPollerIdx(Number.parseInt(pollerInput, 10) || 300); - const pollerFillPct = (pollerIdx / (POLLER_TIERS.length - 1)) * 100; - - // 连接 / LLM 编辑:改本地 state + 自动写入 config.yaml(防丢失),但不应用到运行时 - //(不 reconfigure;重启或点底栏「保存」才生效)。其余配置(规则/轮询/缓存)仍纯草稿。 - const autosaveDraft = ( - nextConnections: Config['connections'], - activeId: string, - nextLlm: Config['llm'], - ): void => { - void invoke('config:autosaveDraft', { - connections: nextConnections, - active_connection_id: activeId, - llm: nextLlm, - }).catch(() => { - /* 自动保存失败不打断编辑;点底栏保存时会再写一次 */ - }); - }; - const persistLlm = (next: Config['llm']): void => { - setLlm(next); - setSaved(false); - // 提升到 App 的 boot.config.llm:重开模态框时 SettingsModal 用最新 prop 重建本地 - // state,新增/编辑的渠道不丢。onLlmChange 只改渲染层 state、不调 config:setLlm, - // 所以是"写入(磁盘+渲染层)但不启用"——运行时仍用旧 active 模型,直到底栏「保存」 - // 走 config:setLlm 或显式切换启用渠道才真正应用。 - onLlmChange?.(next); - autosaveDraft(connections, activeConnId, next); - }; - const openAddProfile = (): void => { - setLlmEditor({ - mode: 'add', - draft: { - id: newProfileId(), - label: '', - provider: 'openai-compatible', - base_url: '', - model: '', - api_key: '', - }, - }); - }; - const openEditProfile = (id: string): void => { - const p = llm.profiles.find((x) => x.id === id); - if (!p) return; - setLlmEditor({ mode: 'edit', draft: { ...p } }); - }; - const closeEditor = (): void => setLlmEditor(null); - const saveEditor = async (): Promise => { - if (!llmEditor) return; - const { mode, draft } = llmEditor; - const profiles = - mode === 'add' - ? [...llm.profiles, draft] - : llm.profiles.map((p) => (p.id === draft.id ? draft : p)); - const active_id = mode === 'add' && !llm.active_id ? draft.id : llm.active_id; - await persistLlm({ profiles, active_id }); - setLlmEditor(null); - }; - const deleteProfile = async (id: string): Promise => { - const profiles = llm.profiles.filter((p) => p.id !== id); - const active_id = llm.active_id === id ? (profiles[0]?.id ?? '') : llm.active_id; - await persistLlm({ profiles, active_id }); - }; - const setActive = async (id: string): Promise => { - if (llm.active_id === id) return; - await persistLlm({ ...llm, active_id: id }); - }; - - // ── 连接:多条可配置 + 单选启用;编辑只改本地 state,整体保存才写盘 + 热重建 ── - const [connections, setConnections] = useState(config.connections); - const [activeConnId, setActiveConnId] = useState(config.active_connection_id); - const [connEditor, setConnEditor] = useState<{ mode: 'add' | 'edit'; draft: ConnDraft } | null>( - null, - ); - const [connDeleteId, setConnDeleteId] = useState(null); - - const persistConnections = (next: Config['connections'], activeId: string): void => { - setConnections(next); - setActiveConnId(activeId); - setSaved(false); - autosaveDraft(next, activeId, llm); - }; - const openAddConn = (): void => { - setConnEditor({ - mode: 'add', - draft: { - id: newProfileId(), - kind: 'github', - display_name: '', - base_url: '', - token: '', - protocol: 'pat', - }, - }); - }; - const openEditConn = (id: string): void => { - const c = connections.find((x) => x.id === id); - if (c) setConnEditor({ mode: 'edit', draft: toConnDraft(c) }); - }; - const saveConnEditor = async (): Promise => { - if (!connEditor) return; - const { mode, draft } = connEditor; - const conn = fromConnDraft(draft); - const next = - mode === 'add' - ? [...connections, conn] - : connections.map((c) => (c.id === conn.id ? conn : c)); - // 新增首条自动设为启用 - const activeId = mode === 'add' && !activeConnId ? conn.id : activeConnId; - await persistConnections(next, activeId); - setConnEditor(null); - }; - const deleteConn = async (id: string): Promise => { - const next = connections.filter((c) => c.id !== id); - // 删的是当前启用 → 启用回退到剩下第一条(无则空串,不轮询任何连接) - const activeId = activeConnId === id ? (next[0]?.id ?? '') : activeConnId; - await persistConnections(next, activeId); - }; - const setActiveConn = (id: string): void => { - if (activeConnId === id) return; - persistConnections(connections, id); - }; - - // ── 变更检测(对比基线)+ 整体保存(仅写有变更的部分,全成功后更新基线)── - const reposDirChanged = reposDirInput.trim() !== base.reposDir; - const agentChanged = agentDirInput.trim() !== base.agentDir; - const pollerChanged = pollerInput.trim() !== String(base.poller); - const llmChanged = JSON.stringify(llm) !== JSON.stringify(base.llm); - const proxyChanged = JSON.stringify(proxy) !== JSON.stringify(base.proxy); - const connectionsChanged = - activeConnId !== base.activeConnId || - JSON.stringify(connections) !== JSON.stringify(base.connections); - const anyChanged = - reposDirChanged || - agentChanged || - pollerChanged || - llmChanged || - proxyChanged || - connectionsChanged; - - const saveAll = async (): Promise => { - if (saving || !anyChanged) return; - setSaving(true); - setSaveError(null); - setSaved(false); - try { - if (pollerChanged) { - const n = Number.parseInt(pollerInput, 10); - if (!Number.isFinite(n) || n < 60 || n > 900) - throw new Error(t('settings.pollerRangeError')); - await invoke('config:setPoller', { interval_seconds: n }); - } - if (agentChanged) { - // 仅 UI 编辑 dir;其余字段(max_steps / summary_max_chars / autopilot)从已加载的 - // config 原样保留,避免被覆盖成默认值。 - await invoke('config:setAgent', { - agent: { ...agent, dir: agentDirInput.trim() }, - }); - } - if (llmChanged) { - await invoke('config:setLlm', { llm }); - onLlmChange?.(llm); - } - if (proxyChanged) { - await invoke('config:setProxy', { proxy }); - // 同步到 App 的 boot.config.proxy:重开设置面板时 SettingsModal 用最新 prop - // 重建本地 state,才能正确回显已保存的代理配置(否则读到启动时的旧值)。 - onProxyChange?.(proxy); - } - if (connectionsChanged) { - await invoke('config:setConnections', { connections, active_connection_id: activeConnId }); - await onConnectionsChange?.(); - } - if (reposDirChanged && reposDirInput.trim()) { - await invoke('config:setReposDir', { reposDir: reposDirInput.trim() }); - } - setBase({ - reposDir: reposDirInput.trim(), - agentDir: agentDirInput.trim(), - poller: Number.parseInt(pollerInput, 10), - llm, - proxy, - connections, - activeConnId, - }); - setSaved(true); - // 保存成功后自动关闭设置面板(失败则保持打开并展示 saveError) - onClose(); - } catch (e) { - setSaveError(e instanceof Error ? e.message : String(e)); - } finally { - setSaving(false); - } - }; + const s = useSettingsDraft({ + config, + paths, + onLlmChange, + onProxyChange, + onLanguageChange, + onConnectionsChange, + onClose, + }); return ( -
-
e.stopPropagation()} role="dialog"> -
-

{t('settings.title')}

- -
-
- {/* 界面语言:即时生效项,放在最前。固定宽度下拉靠右(标题两端对齐);选项用各 - 语言自身的 endonym,不随 UI 语言翻译。 */} -
-
-

{t('settings.languageTitle')}

- -
-

- {t('settings.languageHint')} -

-
- -
-
-

{t('settings.connectionsTitle')}

- -
-

- {t('settings.connectionsHint')} -

- {connections.length === 0 ? ( -

{t('settings.connectionsEmpty')}

- ) : ( -
- {connections.map((c) => { - const isActive = c.id === activeConnId; - const platformMeta = PLATFORM_META.find((m) => m.kind === c.kind); - return ( -
- - {platformMeta && ( - - - - )} -
-
- {c.display_name || c.id} -
-
- {c.base_url} · clone via {c.clone.protocol === 'ssh' ? 'SSH' : 'PAT'} -
-
- - -
- ); - })} -
- )} -
- -
-

{t('settings.pollerTitle')}

-

- {t('settings.pollerHint')} -

-
-
- { - const idx = Number.parseInt(e.target.value, 10); - setPollerInput(String(POLLER_TIERS[idx])); - setSaved(false); - }} - aria-label={t('settings.pollerSliderAria')} - /> - {/* 档位刻度:按 thumb 实际停靠位置绝对定位(thumb 宽 12px,两端内缩 6px), - translateX(-50%) 居中对齐;当前档位高亮 */} -
- {POLLER_TIERS.map((t, i) => { - const frac = i / (POLLER_TIERS.length - 1); - return ( - - {t} - - ); - })} -
-
- - {t('settings.pollerSeconds', { n: pollerInput })} - -
-
- -
-
-

{t('settings.llmTitle')}

- -
-

- {t('settings.llmHint')} -

- {llm.profiles.length === 0 ? ( -

{t('settings.llmEmpty')}

- ) : ( -
- {llm.profiles.map((p) => { - const isActive = p.id === llm.active_id; - const titleText = - p.label || t('settings.llmProfileFallback', { id: p.id.slice(0, 4) }); - const isCli = p.provider === 'cli'; - return ( -
- - - - -
-
- {titleText} - {isCli && ( - - {t('settings.experimental')} - - )} -
-
- {providerLabel(p.provider)} - {p.model ? ` · ${p.model}` : ''} -
-
- - -
- ); - })} -
- )} -
- -
-
-
-

{t('settings.proxyTitle')}

- {/* 启用状态用 chip 表达(绿=已启用/灰=未启用),与应用其它状态视觉一致; - 地址不在此展示,详情见「配置」弹窗。 */} - - {proxy.enabled && proxy.host - ? t('settings.proxyEnabledStatus') - : t('settings.proxyDisabledStatus')} - -
- -
-

- {t('settings.proxyStatusHint')} -

-
- -
- {/* 标题行:左侧标题 + 右侧蓝色「打开当前目录」按钮(在系统文件管理器打开生效的 Agent 目录, - 便于直接查看 / 编辑文件)。放在标题行而非配置行,避免与下方的目录选择按钮混淆。 */} -
-

{t('settings.agentDirTitle')}

- {/* 文案按钮(非图标):与下方的目录「选择」图标按钮区分开,避免混淆。尺寸与其它区块标题行 - 的操作按钮(添加连接 / 添加配置 / 代理配置)一致,统一用 btn-sm。 */} - -
-

- {t('settings.agentDirHint')} -

-
- { - setAgentDirInput(e.target.value); - setSaved(false); - }} - placeholder={t('settings.agentDirPlaceholder')} - /> + <> + +
-
- -
-

{t('settings.workDirTitle')}

-
-
{t('settings.appRoot')}
-
{paths.appDir}
-
{t('settings.configKey')}
-
{paths.configFile}
-
-
- -
-

{t('settings.cacheDirTitle')}

-

- {t('settings.cacheDirHint')} -

-
-
{t('settings.currentDir')}
-
{paths.reposDir}
-
{t('settings.cacheUsage')}
-
- {totalBytes === null ? t('settings.calculating') : formatBytes(totalBytes)} -
-
-
- { - setReposDirInput(e.target.value); - setSaved(false); - }} - placeholder="~/.code-meeseeks/repos" - /> +
+ {(s.saveError ?? s.openError) && ( + {s.saveError ?? s.openError} + )} + {s.saved && !s.anyChanged && {t('settings.saved')}}
-

{t('settings.cacheDirRestartNote')}

-
+ + } + > + + + + + s.setProxyEditor(s.proxy)} /> + void s.pickAgentDir()} + /> + + void s.pickReposDir()} + totalBytes={s.totalBytes} + /> + + -
-

{t('settings.runtimeTitle')}

-
-
{t('settings.appVersion')}
-
{info.appVersion}
-
Electron
-
{info.electronVersion}
-
Node
-
{info.nodeVersion}
-
{t('settings.platform')}
-
{info.platform}
-
-
- - -
- {/* 关于 & 反馈:低频社区链接。http(s) 外链由 App 顶层点击拦截走 openExternal 在系统浏览器打开。 */} - -
-
-
-
- -
-
- {(saveError ?? openError) && ( - {saveError ?? openError} - )} - {saved && !anyChanged && {t('settings.saved')}} - -
-
-
- {llmEditor && ( + {s.llmEditor && ( setLlmEditor({ ...llmEditor, draft })} - onSave={() => void saveEditor()} - onCancel={closeEditor} + state={s.llmEditor} + existing={s.llm.profiles} + onChange={(draft) => s.setLlmEditor({ ...s.llmEditor!, draft })} + onSave={() => void s.saveLlmEditor()} + onCancel={s.closeLlmEditor} /> )} - {connEditor && ( + {s.connEditor && ( setConnEditor({ ...connEditor, draft })} - onSave={() => void saveConnEditor()} - onCancel={() => setConnEditor(null)} + state={s.connEditor} + onChange={(draft) => s.setConnEditor({ ...s.connEditor!, draft })} + onSave={() => s.saveConnEditor()} + onCancel={() => s.setConnEditor(null)} /> )} - {proxyEditor && ( + {s.proxyEditor && ( { - setProxy(proxyEditor); - setProxyEditor(null); - setSaved(false); - }} - onCancel={() => setProxyEditor(null)} + draft={s.proxyEditor} + onChange={s.setProxyEditor} + onSave={() => s.saveProxyEditor()} + onCancel={() => s.setProxyEditor(null)} /> )} - {connDeleteId && ( + {s.connDeleteId && ( c.id === connDeleteId)?.display_name || connDeleteId, + name: + s.connections.find((c) => c.id === s.connDeleteId)?.display_name || s.connDeleteId, })} confirmLabel={t('common.delete')} danger onConfirm={() => { - deleteConn(connDeleteId); - setConnDeleteId(null); + s.deleteConn(s.connDeleteId!); + s.setConnDeleteId(null); }} - onCancel={() => setConnDeleteId(null)} + onCancel={() => s.setConnDeleteId(null)} /> )} -
- ); -} - -/** 「检查更新」按钮(运行环境段):手动查 GitHub 最新版,自管 loading + 结果展示。 - * enabled=false(config.update.check_enabled 关闭)时禁用按钮并提示,不发起检测。 */ -function UpdateCheckButton({ enabled }: { enabled: boolean }) { - const { t } = useTranslation(); - const [checking, setChecking] = useState(false); - const [result, setResult] = useState(null); - if (!enabled) { - return ( - <> - - {t('settings.updateDisabledHint')} - - ); - } - const run = async (): Promise => { - setChecking(true); - try { - setResult(await invoke('app:checkUpdate', undefined)); - } catch (e) { - setResult({ - ok: false, - hasUpdate: false, - currentVersion: '', - error: e instanceof Error ? e.message : String(e), - }); - } finally { - setChecking(false); - } - }; - return ( - <> - - {result && - !checking && - (result.ok ? ( - result.hasUpdate ? ( - - ) : ( - {t('settings.upToDate')} - ) - ) : ( - {t('settings.checkFailed', { error: result.error })} - ))} ); } - -function ConnectionEditorModal({ - state, - onChange, - onSave, - onCancel, -}: { - state: { mode: 'add' | 'edit'; draft: ConnDraft }; - onChange: (draft: ConnDraft) => void; - onSave: () => void; - onCancel: () => void; -}) { - const { t } = useTranslation(); - const { mode, draft } = state; - const canSave = connDraftCanSave(draft); - return ( - // 二层模态:背景点击只关本层,stopPropagation 防冒泡到设置主模态的 onClose(否则会连设置一起关) -
{ - e.stopPropagation(); - onCancel(); - }} - > -
e.stopPropagation()} role="dialog"> -
-

- {mode === 'add' ? t('settings.addConnectionTitle') : t('settings.editConnectionTitle')} -

-
-
- {/* 平台选择仅新增时可改;编辑既有连接不允许切平台(base_url/token 语义不同) */} - {mode === 'add' && ( -
-
{t('settings.platform')}
-
- -
-
- )} - -
- - -
-
-
-
- ); -} - -function ProxyEditorModal({ - draft, - onChange, - onSave, - onCancel, -}: { - draft: Config['proxy']; - onChange: (next: Config['proxy']) => void; - onSave: () => void; - onCancel: () => void; -}) { - const { t } = useTranslation(); - const [test, setTest] = useState<{ - testing: boolean; - result: { ok: boolean; reason?: string } | null; - }>({ testing: false, result: null }); - const [pwVisible, setPwVisible] = useState(false); - // 改任意字段都清掉上次测试结果(避免误导) - const patch = (p: Partial): void => { - onChange({ ...draft, ...p }); - setTest({ testing: false, result: null }); - }; - return ( - // 二层模态:背景点击只关本层,stopPropagation 防冒泡到设置主模态的 onClose(否则会连设置一起关) -
{ - e.stopPropagation(); - onCancel(); - }} - > -
e.stopPropagation()} - role="dialog" - > -
-

{t('settings.proxyTitle')}

-
-
-

- {t('settings.proxyModalHint')} -

- - {draft.enabled && ( - <> - {/* 字段名放输入框前(modal-kv 网格);用户名 / 密码分上下两行,均可选 */} -
-
{t('settings.proxyHost')}
-
- patch({ host: e.target.value.trim() })} - placeholder={t('settings.proxyHostPlaceholder')} - aria-label={t('settings.proxyHostAria')} - /> -
-
{t('settings.proxyPort')}
-
- patch({ port: Number.parseInt(e.target.value, 10) || 0 })} - aria-label={t('settings.proxyPortAria')} - /> -
-
{t('settings.proxyUsername')}
-
- patch({ username: e.target.value })} - placeholder={t('settings.proxyUsernamePlaceholder')} - aria-label={t('settings.proxyUsernameAria')} - autoComplete="off" - /> -
-
{t('settings.proxyPassword')}
-
-
- patch({ password: e.target.value })} - placeholder={t('settings.proxyPasswordPlaceholder')} - aria-label={t('settings.proxyPasswordAria')} - autoComplete="off" - /> - -
-
-
-
- - {test.result && - (test.result.ok ? ( - - {t('settings.proxyOk')} - - ) : ( - - ✗ {test.result.reason ?? t('settings.testFailed')} - - ))} -
- - )} -
- - -
-
-
-
- ); -} - -function LlmEditorModal({ - state, - existing, - onChange, - onSave, - onCancel, -}: { - state: { mode: 'add' | 'edit'; draft: LlmProfile }; - existing: LlmProfile[]; - onChange: (draft: LlmProfile) => void; - onSave: () => void; - onCancel: () => void; -}) { - const { t } = useTranslation(); - const { mode, draft } = state; - // 点保存才把所有必填项暴露出来(LlmProfileForm 内部按 touched 渐进显示) - const [forceShowErrors, setForceShowErrors] = useState(false); - const isValid = Object.keys(validateProfile(draft, existing)).length === 0; - const trySave = (): void => { - if (!isValid) { - setForceShowErrors(true); - return; - } - onSave(); - }; - return ( - // 二层模态:背景点击只关本层,stopPropagation 防冒泡到设置主模态的 onClose(否则会连设置一起关) -
{ - e.stopPropagation(); - onCancel(); - }} - > -
e.stopPropagation()} role="dialog"> -
-

{mode === 'add' ? t('settings.addLlmTitle') : t('settings.editLlmTitle')}

-
-
- -
- - -
-
-
-
- ); -} - -function formatBytes(n: number): string { - if (n < 1024) return `${n} B`; - if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; - if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`; - return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`; -} diff --git a/apps/desktop/src/renderer/src/components/features/settings/editors/ConnectionEditorModal.tsx b/apps/desktop/src/renderer/src/components/features/settings/editors/ConnectionEditorModal.tsx new file mode 100644 index 0000000..e75adff --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/editors/ConnectionEditorModal.tsx @@ -0,0 +1,63 @@ +import { useTranslation } from 'react-i18next'; +import { Modal } from '../../../common/Modal'; +import { ConnectionForm, connDraftCanSave, type ConnDraft } from '../ConnectionForm'; + +export function ConnectionEditorModal({ + state, + onChange, + onSave, + onCancel, +}: { + state: { mode: 'add' | 'edit'; draft: ConnDraft }; + onChange: (draft: ConnDraft) => void; + onSave: () => void; + onCancel: () => void; +}) { + const { t } = useTranslation(); + const { mode, draft } = state; + const canSave = connDraftCanSave(draft); + return ( + + {/* 平台选择仅新增时可改;编辑既有连接不允许切平台(base_url/token 语义不同) */} + {mode === 'add' && ( +
+
{t('settings.platform')}
+
+ +
+
+ )} + +
+ + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/editors/LlmEditorModal.tsx b/apps/desktop/src/renderer/src/components/features/settings/editors/LlmEditorModal.tsx new file mode 100644 index 0000000..925056a --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/editors/LlmEditorModal.tsx @@ -0,0 +1,61 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { LlmProfile } from '@meebox/shared'; +import { Modal } from '../../../common/Modal'; +import { LlmProfileForm, validateProfile } from '../LlmProfileForm'; + +export function LlmEditorModal({ + state, + existing, + onChange, + onSave, + onCancel, +}: { + state: { mode: 'add' | 'edit'; draft: LlmProfile }; + existing: LlmProfile[]; + onChange: (draft: LlmProfile) => void; + onSave: () => void; + onCancel: () => void; +}) { + const { t } = useTranslation(); + const { mode, draft } = state; + // 点保存才把所有必填项暴露出来(LlmProfileForm 内部按 touched 渐进显示) + const [forceShowErrors, setForceShowErrors] = useState(false); + const isValid = Object.keys(validateProfile(draft, existing)).length === 0; + const trySave = (): void => { + if (!isValid) { + setForceShowErrors(true); + return; + } + onSave(); + }; + return ( + + +
+ + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/editors/ProxyEditorModal.tsx b/apps/desktop/src/renderer/src/components/features/settings/editors/ProxyEditorModal.tsx new file mode 100644 index 0000000..982c919 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/editors/ProxyEditorModal.tsx @@ -0,0 +1,165 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { Config } from '@meebox/shared'; +import { Modal } from '../../../common/Modal'; +import { EyeIcon, EyeOffIcon } from '../../../common/icons'; +import { invoke } from '../../../../api'; + +export function ProxyEditorModal({ + draft, + onChange, + onSave, + onCancel, +}: { + draft: Config['proxy']; + onChange: (next: Config['proxy']) => void; + onSave: () => void; + onCancel: () => void; +}) { + const { t } = useTranslation(); + const [test, setTest] = useState<{ + testing: boolean; + result: { ok: boolean; reason?: string } | null; + }>({ testing: false, result: null }); + const [pwVisible, setPwVisible] = useState(false); + // 改任意字段都清掉上次测试结果(避免误导) + const patch = (p: Partial): void => { + onChange({ ...draft, ...p }); + setTest({ testing: false, result: null }); + }; + return ( + +

+ {t('settings.proxyModalHint')} +

+ + {draft.enabled && ( + <> + {/* 字段名放输入框前(modal-kv 网格);用户名 / 密码分上下两行,均可选 */} +
+
{t('settings.proxyHost')}
+
+ patch({ host: e.target.value.trim() })} + placeholder={t('settings.proxyHostPlaceholder')} + aria-label={t('settings.proxyHostAria')} + /> +
+
{t('settings.proxyPort')}
+
+ patch({ port: Number.parseInt(e.target.value, 10) || 0 })} + aria-label={t('settings.proxyPortAria')} + /> +
+
{t('settings.proxyUsername')}
+
+ patch({ username: e.target.value })} + placeholder={t('settings.proxyUsernamePlaceholder')} + aria-label={t('settings.proxyUsernameAria')} + autoComplete="off" + /> +
+
{t('settings.proxyPassword')}
+
+
+ patch({ password: e.target.value })} + placeholder={t('settings.proxyPasswordPlaceholder')} + aria-label={t('settings.proxyPasswordAria')} + autoComplete="off" + /> + +
+
+
+
+ + {test.result && + (test.result.ok ? ( + + {t('settings.proxyOk')} + + ) : ( + ✗ {test.result.reason ?? t('settings.testFailed')} + ))} +
+ + )} +
+ + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/elements/UpdateCheckButton.tsx b/apps/desktop/src/renderer/src/components/features/settings/elements/UpdateCheckButton.tsx new file mode 100644 index 0000000..ededb83 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/elements/UpdateCheckButton.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { UpdateCheckResult } from '@meebox/shared'; +import { invoke } from '../../../../api'; + +/** 「检查更新」按钮(运行环境段):手动查 GitHub 最新版,自管 loading + 结果展示。 */ +export function UpdateCheckButton({ enabled }: { enabled: boolean }) { + const { t } = useTranslation(); + const [checking, setChecking] = useState(false); + const [result, setResult] = useState(null); + if (!enabled) { + return ( + <> + + {t('settings.updateDisabledHint')} + + ); + } + const run = async (): Promise => { + setChecking(true); + try { + setResult(await invoke('app:checkUpdate', undefined)); + } catch (e) { + setResult({ + ok: false, + hasUpdate: false, + currentVersion: '', + error: e instanceof Error ? e.message : String(e), + }); + } finally { + setChecking(false); + } + }; + return ( + <> + + {result && + !checking && + (result.ok ? ( + result.hasUpdate ? ( + + ) : ( + {t('settings.upToDate')} + ) + ) : ( + {t('settings.checkFailed', { error: result.error })} + ))} + + ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/hooks/useSettingsDraft.ts b/apps/desktop/src/renderer/src/components/features/settings/hooks/useSettingsDraft.ts new file mode 100644 index 0000000..847380a --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/hooks/useSettingsDraft.ts @@ -0,0 +1,370 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { AppPaths, Config, LlmProfile, SupportedLanguage } from '@meebox/shared'; +import { invoke } from '../../../../api'; +import i18n, { persistLanguage, resolveUiLanguage } from '../../../../i18n'; +import { fromConnDraft, toConnDraft, type ConnDraft } from '../ConnectionForm'; +import { newProfileId } from '../LlmProfileForm'; + +interface UseSettingsDraftParams { + config: Config; + paths: AppPaths; + onLlmChange?: (llm: Config['llm']) => void; + onProxyChange?: (proxy: Config['proxy']) => void; + onLanguageChange?: (language: SupportedLanguage) => void; + onConnectionsChange?: () => void | Promise; + onClose: () => void; +} + +/** + * SettingsModal 的「草稿 → 整体保存」状态机:所有编辑只改本地 state,点底栏「保存」才整体写盘 + + * 生效;连接 / LLM 改动额外自动写入 config.yaml(防丢失)但不应用到运行时。语言为即时生效项(不走全局 + * 保存)。对外暴露各分区所需的语义化 state 与 setter(编辑即标脏),以及编辑器弹窗状态与 saveAll。 + */ +export function useSettingsDraft({ + config, + paths, + onLlmChange, + onProxyChange, + onLanguageChange, + onConnectionsChange, + onClose, +}: UseSettingsDraftParams) { + const { t } = useTranslation(); + const [opening, setOpening] = useState(false); + const [openError, setOpenError] = useState(null); + + // 草稿 → 整体保存:所有编辑只改本地 state,点底栏"保存"才整体写盘 + 生效 + const [reposDirInput, setReposDirInput] = useState(config.workspace.repos_dir); + // Agent 其余字段(max_steps / summary_max_chars / autopilot)在 UI 不编辑,仅持有以便保存时 + // 原样回传、不被覆盖成默认值;只有目录经 agentDirInput 可编辑。 + const [agent] = useState(config.agent); + const [agentDirInput, setAgentDirInput] = useState(config.agent.dir); + const [pollerInput, setPollerInput] = useState(String(config.poller.interval_seconds)); + const [llm, setLlm] = useState(config.llm); + const [llmEditor, setLlmEditor] = useState<{ mode: 'add' | 'edit'; draft: LlmProfile } | null>( + null, + ); + const [proxy, setProxy] = useState(config.proxy); + // 代理在独立模态框里编辑:null=关闭,非 null=正在编辑的草稿;保存回 proxy,底栏「保存」才写盘。 + const [proxyEditor, setProxyEditor] = useState(null); + + // 连接:多条可配置 + 单选启用;编辑只改本地 state,整体保存才写盘 + 热重建 + const [connections, setConnections] = useState(config.connections); + const [activeConnId, setActiveConnId] = useState(config.active_connection_id); + const [connEditor, setConnEditor] = useState<{ mode: 'add' | 'edit'; draft: ConnDraft } | null>( + null, + ); + const [connDeleteId, setConnDeleteId] = useState(null); + + // 保存基线:保存成功后更新,用于 changed 判定(禁用保存按钮) + const [base, setBase] = useState(() => ({ + reposDir: config.workspace.repos_dir, + agentDir: config.agent.dir, + poller: config.poller.interval_seconds, + llm: config.llm, + proxy: config.proxy, + connections: config.connections, + activeConnId: config.active_connection_id, + })); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [saveError, setSaveError] = useState(null); + + // UI 语言:即时生效项(不走全局保存) + const [language, setLanguage] = useState(() => + resolveUiLanguage(config.language), + ); + const handleLanguageChange = (next: SupportedLanguage): void => { + if (next === language) return; + setLanguage(next); + void i18n.changeLanguage(next); // 渲染层实时切换 + persistLanguage(next); // localStorage 缓存,下次启动同步命中 + onLanguageChange?.(next); // 同步父级 boot.config.language + invoke('config:setLanguage', { language: next }).catch((e: unknown) => { + // 写盘 / 主进程切换失败不回滚 UI(已切),仅提示;下次启动按 localStorage 兜底 + setSaveError(e instanceof Error ? e.message : String(e)); + }); + }; + + const [totalBytes, setTotalBytes] = useState(null); + useEffect(() => { + invoke('repo:getTotalSize', undefined) + .then((r) => setTotalBytes(r.totalBytes)) + .catch(() => setTotalBytes(0)); + }, []); + + const openConfigFile = async (): Promise => { + setOpening(true); + setOpenError(null); + try { + await invoke('app:openConfigFile', undefined); + } catch (e) { + setOpenError(e instanceof Error ? e.message : String(e)); + } finally { + setOpening(false); + } + }; + + // 连接 / LLM 编辑:改本地 state + 自动写入 config.yaml(防丢失),但不应用到运行时 + const autosaveDraft = ( + nextConnections: Config['connections'], + activeId: string, + nextLlm: Config['llm'], + ): void => { + void invoke('config:autosaveDraft', { + connections: nextConnections, + active_connection_id: activeId, + llm: nextLlm, + }).catch(() => { + /* 自动保存失败不打断编辑;点底栏保存时会再写一次 */ + }); + }; + + // ── LLM 配置 ── + const persistLlm = (next: Config['llm']): void => { + setLlm(next); + setSaved(false); + onLlmChange?.(next); + autosaveDraft(connections, activeConnId, next); + }; + const openAddProfile = (): void => { + setLlmEditor({ + mode: 'add', + draft: { + id: newProfileId(), + label: '', + provider: 'openai-compatible', + base_url: '', + model: '', + api_key: '', + }, + }); + }; + const openEditProfile = (id: string): void => { + const p = llm.profiles.find((x) => x.id === id); + if (!p) return; + setLlmEditor({ mode: 'edit', draft: { ...p } }); + }; + const closeLlmEditor = (): void => setLlmEditor(null); + const saveLlmEditor = async (): Promise => { + if (!llmEditor) return; + const { mode, draft } = llmEditor; + const profiles = + mode === 'add' + ? [...llm.profiles, draft] + : llm.profiles.map((p) => (p.id === draft.id ? draft : p)); + const active_id = mode === 'add' && !llm.active_id ? draft.id : llm.active_id; + persistLlm({ profiles, active_id }); + setLlmEditor(null); + }; + const deleteProfile = (id: string): void => { + const profiles = llm.profiles.filter((p) => p.id !== id); + const active_id = llm.active_id === id ? (profiles[0]?.id ?? '') : llm.active_id; + persistLlm({ profiles, active_id }); + }; + const setActiveLlm = (id: string): void => { + if (llm.active_id === id) return; + persistLlm({ ...llm, active_id: id }); + }; + + // ── 连接 ── + const persistConnections = (next: Config['connections'], activeId: string): void => { + setConnections(next); + setActiveConnId(activeId); + setSaved(false); + autosaveDraft(next, activeId, llm); + }; + const openAddConn = (): void => { + setConnEditor({ + mode: 'add', + draft: { + id: newProfileId(), + kind: 'github', + display_name: '', + base_url: '', + token: '', + protocol: 'pat', + }, + }); + }; + const openEditConn = (id: string): void => { + const c = connections.find((x) => x.id === id); + if (c) setConnEditor({ mode: 'edit', draft: toConnDraft(c) }); + }; + const saveConnEditor = (): void => { + if (!connEditor) return; + const { mode, draft } = connEditor; + const conn = fromConnDraft(draft); + const next = + mode === 'add' ? [...connections, conn] : connections.map((c) => (c.id === conn.id ? conn : c)); + // 新增首条自动设为启用 + const activeId = mode === 'add' && !activeConnId ? conn.id : activeConnId; + persistConnections(next, activeId); + setConnEditor(null); + }; + const deleteConn = (id: string): void => { + const next = connections.filter((c) => c.id !== id); + // 删的是当前启用 → 启用回退到剩下第一条(无则空串,不轮询任何连接) + const activeId = activeConnId === id ? (next[0]?.id ?? '') : activeConnId; + persistConnections(next, activeId); + }; + const setActiveConn = (id: string): void => { + if (activeConnId === id) return; + persistConnections(connections, id); + }; + + // ── 代理 ── + const saveProxyEditor = (): void => { + if (!proxyEditor) return; + setProxy(proxyEditor); + setProxyEditor(null); + setSaved(false); + }; + + // ── 目录 / 轮询的语义化 setter(编辑即标脏)── + const setPoller = (seconds: number): void => { + setPollerInput(String(seconds)); + setSaved(false); + }; + const setAgentDir = (v: string): void => { + setAgentDirInput(v); + setSaved(false); + }; + const setReposDir = (v: string): void => { + setReposDirInput(v); + setSaved(false); + }; + const pickAgentDir = async (): Promise => { + const r = await invoke('dialog:pickDirectory', { + defaultPath: agentDirInput.trim() || paths.appDir, + title: t('settings.pickAgentDirTitle'), + }); + if (r.path) setAgentDir(r.path); + }; + const pickReposDir = async (): Promise => { + const r = await invoke('dialog:pickDirectory', { + defaultPath: reposDirInput.trim() || paths.reposDir, + title: t('settings.pickCacheDirTitle'), + }); + if (r.path) setReposDir(r.path); + }; + + // ── 变更检测(对比基线)+ 整体保存 ── + const reposDirChanged = reposDirInput.trim() !== base.reposDir; + const agentChanged = agentDirInput.trim() !== base.agentDir; + const pollerChanged = pollerInput.trim() !== String(base.poller); + const llmChanged = JSON.stringify(llm) !== JSON.stringify(base.llm); + const proxyChanged = JSON.stringify(proxy) !== JSON.stringify(base.proxy); + const connectionsChanged = + activeConnId !== base.activeConnId || + JSON.stringify(connections) !== JSON.stringify(base.connections); + const anyChanged = + reposDirChanged || + agentChanged || + pollerChanged || + llmChanged || + proxyChanged || + connectionsChanged; + + const saveAll = async (): Promise => { + if (saving || !anyChanged) return; + setSaving(true); + setSaveError(null); + setSaved(false); + try { + if (pollerChanged) { + const n = Number.parseInt(pollerInput, 10); + if (!Number.isFinite(n) || n < 60 || n > 900) throw new Error(t('settings.pollerRangeError')); + await invoke('config:setPoller', { interval_seconds: n }); + } + if (agentChanged) { + // 仅 UI 编辑 dir;其余字段从已加载的 config 原样保留,避免被覆盖成默认值。 + await invoke('config:setAgent', { agent: { ...agent, dir: agentDirInput.trim() } }); + } + if (llmChanged) { + await invoke('config:setLlm', { llm }); + onLlmChange?.(llm); + } + if (proxyChanged) { + await invoke('config:setProxy', { proxy }); + onProxyChange?.(proxy); + } + if (connectionsChanged) { + await invoke('config:setConnections', { connections, active_connection_id: activeConnId }); + await onConnectionsChange?.(); + } + if (reposDirChanged && reposDirInput.trim()) { + await invoke('config:setReposDir', { reposDir: reposDirInput.trim() }); + } + setBase({ + reposDir: reposDirInput.trim(), + agentDir: agentDirInput.trim(), + poller: Number.parseInt(pollerInput, 10), + llm, + proxy, + connections, + activeConnId, + }); + setSaved(true); + // 保存成功后自动关闭设置面板(失败则保持打开并展示 saveError) + onClose(); + } catch (e) { + setSaveError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(false); + } + }; + + return { + // 语言 + language, + handleLanguageChange, + // 连接 + connections, + activeConnId, + connEditor, + setConnEditor, + connDeleteId, + setConnDeleteId, + openAddConn, + openEditConn, + saveConnEditor, + deleteConn, + setActiveConn, + // LLM + llm, + llmEditor, + setLlmEditor, + openAddProfile, + openEditProfile, + closeLlmEditor, + saveLlmEditor, + deleteProfile, + setActiveLlm, + // 代理 + proxy, + proxyEditor, + setProxyEditor, + saveProxyEditor, + // 轮询 / 目录 + pollerInput, + setPoller, + agentDirInput, + setAgentDir, + pickAgentDir, + reposDirInput, + setReposDir, + pickReposDir, + totalBytes, + // 保存 / 配置文件 + opening, + openError, + openConfigFile, + saving, + saved, + saveError, + anyChanged, + saveAll, + }; +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/AgentDirSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/AgentDirSection.tsx new file mode 100644 index 0000000..13c10c9 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/AgentDirSection.tsx @@ -0,0 +1,54 @@ +import { useTranslation } from 'react-i18next'; +import { FolderIcon } from '../../../common/icons'; +import { invoke } from '../../../../api'; + +export function AgentDirSection({ + value, + onChange, + onPick, +}: { + value: string; + onChange: (v: string) => void; + onPick: () => void; +}) { + const { t } = useTranslation(); + return ( +
+ {/* 标题行:左侧标题 + 右侧蓝色「打开当前目录」按钮(在系统文件管理器打开生效的 Agent 目录, + 便于直接查看 / 编辑文件)。放在标题行而非配置行,避免与下方的目录选择按钮混淆。 */} +
+

{t('settings.agentDirTitle')}

+ {/* 文案按钮(非图标):与下方的目录「选择」图标按钮区分开,避免混淆。 */} + +
+

+ {t('settings.agentDirHint')} +

+
+ onChange(e.target.value)} + placeholder={t('settings.agentDirPlaceholder')} + /> + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/CacheDirSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/CacheDirSection.tsx new file mode 100644 index 0000000..7980d52 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/CacheDirSection.tsx @@ -0,0 +1,55 @@ +import { useTranslation } from 'react-i18next'; +import type { AppPaths } from '@meebox/shared'; +import { FolderIcon } from '../../../common/icons'; +import { formatBytes } from '../utils'; + +export function CacheDirSection({ + paths, + value, + onChange, + onPick, + totalBytes, +}: { + paths: AppPaths; + value: string; + onChange: (v: string) => void; + onPick: () => void; + totalBytes: number | null; +}) { + const { t } = useTranslation(); + return ( +
+

{t('settings.cacheDirTitle')}

+

+ {t('settings.cacheDirHint')} +

+
+
{t('settings.currentDir')}
+
{paths.reposDir}
+
{t('settings.cacheUsage')}
+
+ {totalBytes === null ? t('settings.calculating') : formatBytes(totalBytes)} +
+
+
+ onChange(e.target.value)} + placeholder="~/.code-meeseeks/repos" + /> + +
+

{t('settings.cacheDirRestartNote')}

+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/ConnectionsSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/ConnectionsSection.tsx new file mode 100644 index 0000000..374a640 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/ConnectionsSection.tsx @@ -0,0 +1,89 @@ +import { useTranslation } from 'react-i18next'; +import type { Config } from '@meebox/shared'; +import { PencilIcon, TrashIcon } from '../../../common/icons'; +import { PLATFORM_META } from '../../../common/PlatformIcon'; + +export function ConnectionsSection({ + connections, + activeConnId, + onAdd, + onEdit, + onSetActive, + onRequestDelete, +}: { + connections: Config['connections']; + activeConnId: string; + onAdd: () => void; + onEdit: (id: string) => void; + onSetActive: (id: string) => void; + onRequestDelete: (id: string) => void; +}) { + const { t } = useTranslation(); + return ( +
+
+

{t('settings.connectionsTitle')}

+ +
+

+ {t('settings.connectionsHint')} +

+ {connections.length === 0 ? ( +

{t('settings.connectionsEmpty')}

+ ) : ( +
+ {connections.map((c) => { + const isActive = c.id === activeConnId; + const platformMeta = PLATFORM_META.find((m) => m.kind === c.kind); + return ( +
+ + {platformMeta && ( + + + + )} +
+
+ {c.display_name || c.id} +
+
+ {c.base_url} · clone via {c.clone.protocol === 'ssh' ? 'SSH' : 'PAT'} +
+
+ + +
+ ); + })} +
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/LanguageSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/LanguageSection.tsx new file mode 100644 index 0000000..031c476 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/LanguageSection.tsx @@ -0,0 +1,34 @@ +import { useTranslation } from 'react-i18next'; +import { LANGUAGE_OPTIONS, type SupportedLanguage } from '@meebox/shared'; + +export function LanguageSection({ + language, + onChange, +}: { + language: SupportedLanguage; + onChange: (next: SupportedLanguage) => void; +}) { + const { t } = useTranslation(); + return ( +
+
+

{t('settings.languageTitle')}

+ +
+

+ {t('settings.languageHint')} +

+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/LlmSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/LlmSection.tsx new file mode 100644 index 0000000..557dc07 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/LlmSection.tsx @@ -0,0 +1,93 @@ +import { useTranslation } from 'react-i18next'; +import type { Config } from '@meebox/shared'; +import { PencilIcon, TrashIcon } from '../../../common/icons'; +import { LlmProviderIcon } from '../../../common/LlmProviderIcon'; +import { providerLabel } from '../LlmProfileForm'; + +export function LlmSection({ + llm, + onAdd, + onEdit, + onSetActive, + onDelete, +}: { + llm: Config['llm']; + onAdd: () => void; + onEdit: (id: string) => void; + onSetActive: (id: string) => void; + onDelete: (id: string) => void; +}) { + const { t } = useTranslation(); + return ( +
+
+

{t('settings.llmTitle')}

+ +
+

+ {t('settings.llmHint')} +

+ {llm.profiles.length === 0 ? ( +

{t('settings.llmEmpty')}

+ ) : ( +
+ {llm.profiles.map((p) => { + const isActive = p.id === llm.active_id; + const titleText = p.label || t('settings.llmProfileFallback', { id: p.id.slice(0, 4) }); + const isCli = p.provider === 'cli'; + return ( +
+ + + + +
+
+ {titleText} + {isCli && ( + + {t('settings.experimental')} + + )} +
+
+ {providerLabel(p.provider)} + {p.model ? ` · ${p.model}` : ''} +
+
+ + +
+ ); + })} +
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/PollerSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/PollerSection.tsx new file mode 100644 index 0000000..8c84c01 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/PollerSection.tsx @@ -0,0 +1,62 @@ +import { useTranslation } from 'react-i18next'; +import type { CSSProperties } from 'react'; +import { POLLER_TIERS, nearestPollerIdx } from '../utils'; + +export function PollerSection({ + value, + onChange, +}: { + /** 当前轮询秒数(字符串形态,与底层 pollerInput 一致) */ + value: string; + /** 选定档位 → 回传该档秒数 */ + onChange: (seconds: number) => void; +}) { + const { t } = useTranslation(); + const pollerIdx = nearestPollerIdx(Number.parseInt(value, 10) || 300); + const pollerFillPct = (pollerIdx / (POLLER_TIERS.length - 1)) * 100; + return ( +
+

{t('settings.pollerTitle')}

+

+ {t('settings.pollerHint')} +

+
+
+ onChange(POLLER_TIERS[Number.parseInt(e.target.value, 10)]!)} + aria-label={t('settings.pollerSliderAria')} + /> + {/* 档位刻度:按 thumb 实际停靠位置绝对定位(thumb 宽 12px,两端内缩 6px), + translateX(-50%) 居中对齐;当前档位高亮 */} +
+ {POLLER_TIERS.map((tier, i) => { + const frac = i / (POLLER_TIERS.length - 1); + return ( + + {tier} + + ); + })} +
+
+ + {t('settings.pollerSeconds', { n: value })} + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/ProxySection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/ProxySection.tsx new file mode 100644 index 0000000..48ebc4a --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/ProxySection.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from 'react-i18next'; +import type { Config } from '@meebox/shared'; + +export function ProxySection({ + proxy, + onConfigure, +}: { + proxy: Config['proxy']; + onConfigure: () => void; +}) { + const { t } = useTranslation(); + const on = proxy.enabled && !!proxy.host; + return ( +
+
+
+

{t('settings.proxyTitle')}

+ {/* 启用状态用 chip 表达(绿=已启用/灰=未启用),与应用其它状态视觉一致; + 地址不在此展示,详情见「配置」弹窗。 */} + + {on ? t('settings.proxyEnabledStatus') : t('settings.proxyDisabledStatus')} + +
+ +
+

+ {t('settings.proxyStatusHint')} +

+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/RuntimeSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/RuntimeSection.tsx new file mode 100644 index 0000000..493e35b --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/RuntimeSection.tsx @@ -0,0 +1,73 @@ +import { useTranslation } from 'react-i18next'; +import type { AppInfo } from '@meebox/shared'; +import { GitHubMarkIcon, IssueIcon, TagIcon } from '../../../common/icons'; +import { invoke } from '../../../../api'; +import { UpdateCheckButton } from '../elements/UpdateCheckButton'; + +export function RuntimeSection({ + info, + updateEnabled, +}: { + info: AppInfo; + updateEnabled: boolean; +}) { + const { t } = useTranslation(); + return ( +
+

{t('settings.runtimeTitle')}

+
+
{t('settings.appVersion')}
+
{info.appVersion}
+
Electron
+
{info.electronVersion}
+
Node
+
{info.nodeVersion}
+
{t('settings.platform')}
+
{info.platform}
+
+
+ + +
+ {/* 关于 & 反馈:低频社区链接。http(s) 外链由 App 顶层点击拦截走 openExternal 在系统浏览器打开。 */} + +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/WorkDirSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/WorkDirSection.tsx new file mode 100644 index 0000000..b625501 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/WorkDirSection.tsx @@ -0,0 +1,17 @@ +import { useTranslation } from 'react-i18next'; +import type { AppPaths } from '@meebox/shared'; + +export function WorkDirSection({ paths }: { paths: AppPaths }) { + const { t } = useTranslation(); + return ( +
+

{t('settings.workDirTitle')}

+
+
{t('settings.appRoot')}
+
{paths.appDir}
+
{t('settings.configKey')}
+
{paths.configFile}
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/utils.ts b/apps/desktop/src/renderer/src/components/features/settings/utils.ts new file mode 100644 index 0000000..53c2b68 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/utils.ts @@ -0,0 +1,20 @@ +// 轮询间隔档位(秒):低值细(30s 一档)、高值粗(分钟级),梯度放大。滑块拖的是 +// 档位索引而非秒数,从而实现非线性步长 + 离散刻度。 +export const POLLER_TIERS = [60, 90, 120, 180, 300, 600, 900]; + +/** 取最接近给定秒数的档位索引(配置值不在档位上时就近吸附) */ +export function nearestPollerIdx(seconds: number): number { + let best = 0; + for (let i = 1; i < POLLER_TIERS.length; i++) { + if (Math.abs(POLLER_TIERS[i]! - seconds) < Math.abs(POLLER_TIERS[best]! - seconds)) best = i; + } + return best; +} + +/** 字节数 → 人类可读(B / KB / MB / GB)。 */ +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`; + return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} From 94c14b2979669bdc4e3b4bd9c9755a92f5fd818a Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Thu, 18 Jun 2026 14:29:29 +0800 Subject: [PATCH 03/14] =?UTF-8?q?refactor(components):=20PR=20=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=E9=9D=A2=E6=9D=BF=20diff/comments/drafts=20=E6=94=B6?= =?UTF-8?q?=E5=BD=92=20features/pr/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit diff / comments / drafts 仅在 PR 详情视图内渲染,属 PR 作用域子领域,迁入 features/pr/ 下,使 pr/ 成为「PR 评审工作区」的统一归属(与 PrItem / PrInfoView / CommitsPanel 同处)。chat / onboarding / settings 仍在 features/ 顶层。纯目录迁移 + import 路径改写(git 识别为 rename),无逻辑/界面变更。 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + .../{ => pr}/comments/CommentEditEditor.tsx | 2 +- .../{ => pr}/comments/CommentReplyEditor.tsx | 2 +- .../{ => pr}/comments/CommentsPanel.tsx | 16 ++++++------ .../{ => pr}/comments/InlineCodeContext.tsx | 8 +++--- .../{ => pr}/diff/DiffSearchPanel.tsx | 4 +-- .../features/{ => pr}/diff/DiffView.tsx | 26 +++++++++---------- .../features/{ => pr}/diff/FileTree.tsx | 2 +- .../features/{ => pr}/drafts/DraftZone.tsx | 4 +-- .../features/{ => pr}/drafts/DraftsPanel.tsx | 6 ++--- .../{ => pr}/drafts/PublishReviewModal.tsx | 2 +- .../src/components/layout/MainPane.tsx | 8 +++--- 12 files changed, 41 insertions(+), 40 deletions(-) rename apps/desktop/src/renderer/src/components/features/{ => pr}/comments/CommentEditEditor.tsx (98%) rename apps/desktop/src/renderer/src/components/features/{ => pr}/comments/CommentReplyEditor.tsx (98%) rename apps/desktop/src/renderer/src/components/features/{ => pr}/comments/CommentsPanel.tsx (97%) rename apps/desktop/src/renderer/src/components/features/{ => pr}/comments/InlineCodeContext.tsx (97%) rename apps/desktop/src/renderer/src/components/features/{ => pr}/diff/DiffSearchPanel.tsx (99%) rename apps/desktop/src/renderer/src/components/features/{ => pr}/diff/DiffView.tsx (99%) rename apps/desktop/src/renderer/src/components/features/{ => pr}/diff/FileTree.tsx (99%) rename apps/desktop/src/renderer/src/components/features/{ => pr}/drafts/DraftZone.tsx (99%) rename apps/desktop/src/renderer/src/components/features/{ => pr}/drafts/DraftsPanel.tsx (98%) rename apps/desktop/src/renderer/src/components/features/{ => pr}/drafts/PublishReviewModal.tsx (99%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34caa4c..0734580 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - `components/` 目录按职责重组:扁平堆叠的组件归入三类——`common/`(基础公共 UI)、`layout/`(应用骨架)、`features/`(业务领域:pr / diff / comments / drafts / settings / chat / onboarding),顶层只剩这三个桶。纯文件位置调整 + import 路径改写,无逻辑 / 界面变更。 - 新增通用 `common/Modal` 模态壳(backdrop / header / body / footer / portal / nested / size 变体),统一各处手写的模态脚手架;ConfirmModal、RulePreviewModal 及设置面板的全部弹窗改用之。 - 设置面板内部重构:1222 行的 `SettingsModal.tsx` 拆分为容器(175 行)+ `useSettingsDraft`(草稿/保存状态机)+ 九个独立 `sections/` 分区 + `editors/`(连接/代理/LLM 编辑器)+ `elements/`(小部件)+ `utils`(formatBytes / 轮询档位),各分区可独立维护。对外接口与界面行为不变。 +- PR 作用域的评审面板(`diff` / `comments` / `drafts`)收归 `features/pr/` 之下,使 `pr/` 成为「PR 评审工作区」的统一归属;`chat` / `onboarding` / `settings` 仍在 `features/` 顶层。纯目录迁移 + import 路径改写,无逻辑 / 界面变更。 ### Fixed diff --git a/apps/desktop/src/renderer/src/components/features/comments/CommentEditEditor.tsx b/apps/desktop/src/renderer/src/components/features/pr/comments/CommentEditEditor.tsx similarity index 98% rename from apps/desktop/src/renderer/src/components/features/comments/CommentEditEditor.tsx rename to apps/desktop/src/renderer/src/components/features/pr/comments/CommentEditEditor.tsx index 2b575ef..d7f19d4 100644 --- a/apps/desktop/src/renderer/src/components/features/comments/CommentEditEditor.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/comments/CommentEditEditor.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { invoke } from '../../../api'; +import { invoke } from '../../../../api'; interface CommentEditEditorProps { prLocalId: string; diff --git a/apps/desktop/src/renderer/src/components/features/comments/CommentReplyEditor.tsx b/apps/desktop/src/renderer/src/components/features/pr/comments/CommentReplyEditor.tsx similarity index 98% rename from apps/desktop/src/renderer/src/components/features/comments/CommentReplyEditor.tsx rename to apps/desktop/src/renderer/src/components/features/pr/comments/CommentReplyEditor.tsx index a2588fd..b99313e 100644 --- a/apps/desktop/src/renderer/src/components/features/comments/CommentReplyEditor.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/comments/CommentReplyEditor.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { invoke } from '../../../api'; +import { invoke } from '../../../../api'; interface CommentReplyEditorProps { prLocalId: string; diff --git a/apps/desktop/src/renderer/src/components/features/comments/CommentsPanel.tsx b/apps/desktop/src/renderer/src/components/features/pr/comments/CommentsPanel.tsx similarity index 97% rename from apps/desktop/src/renderer/src/components/features/comments/CommentsPanel.tsx rename to apps/desktop/src/renderer/src/components/features/pr/comments/CommentsPanel.tsx index 5202525..24929d2 100644 --- a/apps/desktop/src/renderer/src/components/features/comments/CommentsPanel.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/comments/CommentsPanel.tsx @@ -4,16 +4,16 @@ import ReactMarkdown from 'react-markdown'; import remarkBreaks from 'remark-breaks'; import remarkGfm from 'remark-gfm'; import type { PlatformCapabilities, PrComment, StoredPullRequest } from '@meebox/shared'; -import { invoke, subscribe } from '../../../api'; -import i18n from '../../../i18n'; -import { formatBackendError, type FormattedError } from '../../../errors'; -import { REMOTE_REHYPE_PLUGINS } from '../../../markdown'; -import { Avatar } from '../../common/Avatar'; -import { makeBitbucketImageFor, transformBitbucketUrl } from '../../common/BitbucketImage'; +import { invoke, subscribe } from '../../../../api'; +import i18n from '../../../../i18n'; +import { formatBackendError, type FormattedError } from '../../../../errors'; +import { REMOTE_REHYPE_PLUGINS } from '../../../../markdown'; +import { Avatar } from '../../../common/Avatar'; +import { makeBitbucketImageFor, transformBitbucketUrl } from '../../../common/BitbucketImage'; import { CommentEditEditor } from './CommentEditEditor'; import { CommentReplyEditor } from './CommentReplyEditor'; -import { ConfirmModal } from '../../common/ConfirmModal'; -import { mermaidComponents } from '../../common/markdownMermaid'; +import { ConfirmModal } from '../../../common/ConfirmModal'; +import { mermaidComponents } from '../../../common/markdownMermaid'; // 行内代码上下文用 Monaco,懒加载随 DiffView 同一套 Monaco chunk 按需拉取,不进入口包。 const InlineCodeContext = lazy(() => import('./InlineCodeContext').then((m) => ({ default: m.InlineCodeContext })), diff --git a/apps/desktop/src/renderer/src/components/features/comments/InlineCodeContext.tsx b/apps/desktop/src/renderer/src/components/features/pr/comments/InlineCodeContext.tsx similarity index 97% rename from apps/desktop/src/renderer/src/components/features/comments/InlineCodeContext.tsx rename to apps/desktop/src/renderer/src/components/features/pr/comments/InlineCodeContext.tsx index b68d854..8076364 100644 --- a/apps/desktop/src/renderer/src/components/features/comments/InlineCodeContext.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/comments/InlineCodeContext.tsx @@ -1,14 +1,14 @@ // 必须在用到 @monaco-editor/react 之前执行(见 DiffView 同款说明)。本文件经 // React.lazy 动态加载 → Monaco 随本 chunk 按需拉取,不进入口包。 -import '../../../monaco-setup'; +import '../../../../monaco-setup'; import { Editor, type Monaco } from '@monaco-editor/react'; import type { editor } from 'monaco-editor'; import { memo, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { PrCommentAnchor, StoredPullRequest } from '@meebox/shared'; -import { invoke } from '../../../api'; -import { editorFontSize } from '../../../editor-font'; -import { languageFor } from '../../../utils/language'; +import { invoke } from '../../../../api'; +import { editorFontSize } from '../../../../editor-font'; +import { languageFor } from '../../../../utils/language'; interface InlineCodeContextProps { pr: StoredPullRequest; diff --git a/apps/desktop/src/renderer/src/components/features/diff/DiffSearchPanel.tsx b/apps/desktop/src/renderer/src/components/features/pr/diff/DiffSearchPanel.tsx similarity index 99% rename from apps/desktop/src/renderer/src/components/features/diff/DiffSearchPanel.tsx rename to apps/desktop/src/renderer/src/components/features/pr/diff/DiffSearchPanel.tsx index eca54e8..26e3afe 100644 --- a/apps/desktop/src/renderer/src/components/features/diff/DiffSearchPanel.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/diff/DiffSearchPanel.tsx @@ -3,8 +3,8 @@ import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { editor as MonacoEditorNs } from 'monaco-editor'; import type { DiffChangedFile } from '@meebox/shared'; -import { invoke } from '../../../api'; -import { languageFor } from '../../../utils/language'; +import { invoke } from '../../../../api'; +import { languageFor } from '../../../../utils/language'; /** * 搜索 PR diff 全部变更文件内容。仿 Bitbucket "Search code" 入口的行为: diff --git a/apps/desktop/src/renderer/src/components/features/diff/DiffView.tsx b/apps/desktop/src/renderer/src/components/features/pr/diff/DiffView.tsx similarity index 99% rename from apps/desktop/src/renderer/src/components/features/diff/DiffView.tsx rename to apps/desktop/src/renderer/src/components/features/pr/diff/DiffView.tsx index 57b8314..424b391 100644 --- a/apps/desktop/src/renderer/src/components/features/diff/DiffView.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/diff/DiffView.tsx @@ -1,6 +1,6 @@ // 必须在用到 @monaco-editor/react 之前执行(loader.config 指向本地 monaco)。 // 本文件经 React.lazy 动态加载,故 Monaco 随本 chunk 按需拉取,不进入口包。 -import '../../../monaco-setup'; +import '../../../../monaco-setup'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { createRoot, type Root } from 'react-dom/client'; @@ -21,23 +21,23 @@ import type { SyncProgressEvent, } from '@meebox/shared'; import { policyForPlatform } from '@meebox/shared'; -import { invoke, subscribe } from '../../../api'; -import { editorFontSize } from '../../../editor-font'; -import { formatBackendError, type FormattedError } from '../../../errors'; -import { REMOTE_REHYPE_PLUGINS } from '../../../markdown'; -import { useDraftsForPr } from '../../../stores/drafts-store'; -import { Avatar } from '../../common/Avatar'; +import { invoke, subscribe } from '../../../../api'; +import { editorFontSize } from '../../../../editor-font'; +import { formatBackendError, type FormattedError } from '../../../../errors'; +import { REMOTE_REHYPE_PLUGINS } from '../../../../markdown'; +import { useDraftsForPr } from '../../../../stores/drafts-store'; +import { Avatar } from '../../../common/Avatar'; import { DraftZone } from '../drafts/DraftZone'; -import { ErrorBoundary } from '../../common/ErrorBoundary'; -import { makeBitbucketImageFor, transformBitbucketUrl } from '../../common/BitbucketImage'; +import { ErrorBoundary } from '../../../common/ErrorBoundary'; +import { makeBitbucketImageFor, transformBitbucketUrl } from '../../../common/BitbucketImage'; import { CommentEditEditor } from '../comments/CommentEditEditor'; import { CommentReplyEditor } from '../comments/CommentReplyEditor'; -import { ConfirmModal } from '../../common/ConfirmModal'; +import { ConfirmModal } from '../../../common/ConfirmModal'; import { DiffSearchPanel } from './DiffSearchPanel'; import { FileTree } from './FileTree'; -import { PaneLoading } from '../../common/Loading'; -import { FileTreeIcon, SearchIcon } from '../../common/icons'; -import { languageFor } from '../../../utils/language'; +import { PaneLoading } from '../../../common/Loading'; +import { FileTreeIcon, SearchIcon } from '../../../common/icons'; +import { languageFor } from '../../../../utils/language'; interface DiffViewProps { pr: StoredPullRequest; diff --git a/apps/desktop/src/renderer/src/components/features/diff/FileTree.tsx b/apps/desktop/src/renderer/src/components/features/pr/diff/FileTree.tsx similarity index 99% rename from apps/desktop/src/renderer/src/components/features/diff/FileTree.tsx rename to apps/desktop/src/renderer/src/components/features/pr/diff/FileTree.tsx index acd2bc3..31de077 100644 --- a/apps/desktop/src/renderer/src/components/features/diff/FileTree.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/diff/FileTree.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { Icon } from '@iconify/react'; import type { DiffChangedFile } from '@meebox/shared'; -import { ChevronIcon } from '../../common/icons'; +import { ChevronIcon } from '../../../common/icons'; interface FileTreeProps { files: DiffChangedFile[]; diff --git a/apps/desktop/src/renderer/src/components/features/drafts/DraftZone.tsx b/apps/desktop/src/renderer/src/components/features/pr/drafts/DraftZone.tsx similarity index 99% rename from apps/desktop/src/renderer/src/components/features/drafts/DraftZone.tsx rename to apps/desktop/src/renderer/src/components/features/pr/drafts/DraftZone.tsx index a4bfade..be63eb2 100644 --- a/apps/desktop/src/renderer/src/components/features/drafts/DraftZone.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/drafts/DraftZone.tsx @@ -4,8 +4,8 @@ import ReactMarkdown from 'react-markdown'; import remarkBreaks from 'remark-breaks'; import remarkGfm from 'remark-gfm'; import type { ReviewDraft } from '@meebox/shared'; -import { ConfirmModal } from '../../common/ConfirmModal'; -import { TrashIcon } from '../../common/icons'; +import { ConfirmModal } from '../../../common/ConfirmModal'; +import { TrashIcon } from '../../../common/icons'; interface DraftZoneProps { draft: ReviewDraft; diff --git a/apps/desktop/src/renderer/src/components/features/drafts/DraftsPanel.tsx b/apps/desktop/src/renderer/src/components/features/pr/drafts/DraftsPanel.tsx similarity index 98% rename from apps/desktop/src/renderer/src/components/features/drafts/DraftsPanel.tsx rename to apps/desktop/src/renderer/src/components/features/pr/drafts/DraftsPanel.tsx index f485fcb..627a0ec 100644 --- a/apps/desktop/src/renderer/src/components/features/drafts/DraftsPanel.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/drafts/DraftsPanel.tsx @@ -4,9 +4,9 @@ import ReactMarkdown from 'react-markdown'; import remarkBreaks from 'remark-breaks'; import remarkGfm from 'remark-gfm'; import type { PlatformCapabilities, ReviewDraft, StoredPullRequest } from '@meebox/shared'; -import { invoke } from '../../../api'; -import { useDraftsForPr } from '../../../stores/drafts-store'; -import { ConfirmModal } from '../../common/ConfirmModal'; +import { invoke } from '../../../../api'; +import { useDraftsForPr } from '../../../../stores/drafts-store'; +import { ConfirmModal } from '../../../common/ConfirmModal'; // posted 已不存在 (发布成功即删本地),筛选项只保留 publishable / all / rejected type Filter = 'all' | 'publishable' | 'rejected'; diff --git a/apps/desktop/src/renderer/src/components/features/drafts/PublishReviewModal.tsx b/apps/desktop/src/renderer/src/components/features/pr/drafts/PublishReviewModal.tsx similarity index 99% rename from apps/desktop/src/renderer/src/components/features/drafts/PublishReviewModal.tsx rename to apps/desktop/src/renderer/src/components/features/pr/drafts/PublishReviewModal.tsx index f9a6440..f9c2965 100644 --- a/apps/desktop/src/renderer/src/components/features/drafts/PublishReviewModal.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/drafts/PublishReviewModal.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { ReviewDraft } from '@meebox/shared'; -import { invoke } from '../../../api'; +import { invoke } from '../../../../api'; /** * 批量发布草稿到 Bitbucket 的确认 modal。M4 发布闭环最后一公里。 diff --git a/apps/desktop/src/renderer/src/components/layout/MainPane.tsx b/apps/desktop/src/renderer/src/components/layout/MainPane.tsx index 57b62c7..010a301 100644 --- a/apps/desktop/src/renderer/src/components/layout/MainPane.tsx +++ b/apps/desktop/src/renderer/src/components/layout/MainPane.tsx @@ -8,15 +8,15 @@ import type { } from '@meebox/shared'; import { invoke } from '../../api'; import { useDraftsForPr } from '../../stores/drafts-store'; -import { CommentsPanel } from '../features/comments/CommentsPanel'; +import { CommentsPanel } from '../features/pr/comments/CommentsPanel'; import { CommitsPanel } from '../features/pr/CommitsPanel'; // Monaco 编辑器(~10MB)懒加载:只有真正切到 Diff tab 才拉取 DiffView chunk, // 不阻塞窗口首帧 / PR 列表 / 首启向导。 -const DiffView = lazy(() => import('../features/diff/DiffView').then((m) => ({ default: m.DiffView }))); -import { DraftsPanel } from '../features/drafts/DraftsPanel'; +const DiffView = lazy(() => import('../features/pr/diff/DiffView').then((m) => ({ default: m.DiffView }))); +import { DraftsPanel } from '../features/pr/drafts/DraftsPanel'; import { PaneLoading } from '../common/Loading'; import { PrInfoView } from '../features/pr/PrInfoView'; -import { PublishReviewModal } from '../features/drafts/PublishReviewModal'; +import { PublishReviewModal } from '../features/pr/drafts/PublishReviewModal'; import { ApproveIcon, GlobeIcon, From 6bd35fdee6eb56837791ef1727a6d4c7618e299c Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Thu, 18 Jun 2026 14:52:23 +0800 Subject: [PATCH 04/14] =?UTF-8?q?refactor(statusbar):=20=E6=8A=BD=E9=80=9A?= =?UTF-8?q?=E7=94=A8=20StatusChip=20+=20chip=20=E6=8C=89=E5=BD=92=E5=B1=9E?= =?UTF-8?q?=E4=B8=8B=E6=B2=89=E5=88=B0=20features?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StatusBar 内部杂乱、各 chip 自带逻辑、无公共 chip 组件,重构: - 新增 common/StatusChip 通用 chip 壳(span/button + ok/err 色调 + 专属类名) - 各业务 chip 按归属下沉到所属 feature 的 statusbar/: · settings: LlmChip · UserChip · chat: PrAgentActiveChip(+QueuePopover) · AutopilotChip · pr: LastSyncChip · RepoSyncChip · PrsCountChip - pr-agent 运行时 chip 与更新 chip(app 运行时级)留 layout - StatusBar 退化为薄壳(610→146 行),只做组合与布局 - 时长 / 相对时间格式化统一抽到 utils/time(formatElapsed 带 compact 选项、formatRelative),消除与 chat 的重复实现(DRY) 无逻辑 / 界面变更。 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + .../src/components/common/StatusChip.tsx | 60 ++ .../features/chat/statusbar/AutopilotChip.tsx | 27 + .../chat/statusbar/PrAgentActiveChip.tsx | 210 +++++++ .../components/features/chat/utils/format.ts | 10 +- .../features/pr/statusbar/LastSyncChip.tsx | 46 ++ .../features/pr/statusbar/PrsCountChip.tsx | 19 + .../features/pr/statusbar/RepoSyncChip.tsx | 37 ++ .../features/settings/statusbar/LlmChip.tsx | 106 ++++ .../features/settings/statusbar/UserChip.tsx | 26 + .../src/components/layout/StatusBar.tsx | 554 ++---------------- apps/desktop/src/renderer/src/utils/time.ts | 30 + 12 files changed, 609 insertions(+), 517 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/common/StatusChip.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/chat/statusbar/AutopilotChip.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/chat/statusbar/PrAgentActiveChip.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/pr/statusbar/LastSyncChip.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/pr/statusbar/PrsCountChip.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/pr/statusbar/RepoSyncChip.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/settings/statusbar/LlmChip.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/settings/statusbar/UserChip.tsx create mode 100644 apps/desktop/src/renderer/src/utils/time.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0734580..f2809e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - 新增通用 `common/Modal` 模态壳(backdrop / header / body / footer / portal / nested / size 变体),统一各处手写的模态脚手架;ConfirmModal、RulePreviewModal 及设置面板的全部弹窗改用之。 - 设置面板内部重构:1222 行的 `SettingsModal.tsx` 拆分为容器(175 行)+ `useSettingsDraft`(草稿/保存状态机)+ 九个独立 `sections/` 分区 + `editors/`(连接/代理/LLM 编辑器)+ `elements/`(小部件)+ `utils`(formatBytes / 轮询档位),各分区可独立维护。对外接口与界面行为不变。 - PR 作用域的评审面板(`diff` / `comments` / `drafts`)收归 `features/pr/` 之下,使 `pr/` 成为「PR 评审工作区」的统一归属;`chat` / `onboarding` / `settings` 仍在 `features/` 顶层。纯目录迁移 + import 路径改写,无逻辑 / 界面变更。 +- 状态栏重构:新增 `common/StatusChip` 通用 chip 壳(span/button + ok/err 色调 + 专属类名),各 chip 复用;`StatusBar` 退化为薄壳(610→146 行),各业务 chip 按归属下沉到所属 feature 的 `statusbar/`——LLM / 用户 → settings,pr-agent 活动队列 / AutoPilot → chat,最近同步 / 仓库镜像 / PR 计数 → pr;pr-agent 运行时与更新 chip 留 layout。时长 / 相对时间格式化统一抽到 `utils/time`(`formatElapsed` 带 `compact` 选项、`formatRelative`),消除与状态栏的重复实现。无逻辑 / 界面变更。 ### Fixed diff --git a/apps/desktop/src/renderer/src/components/common/StatusChip.tsx b/apps/desktop/src/renderer/src/components/common/StatusChip.tsx new file mode 100644 index 0000000..40e2c8b --- /dev/null +++ b/apps/desktop/src/renderer/src/components/common/StatusChip.tsx @@ -0,0 +1,60 @@ +import type { ReactNode } from 'react'; + +type ChipTone = 'ok' | 'err'; + +interface StatusChipProps { + /** 渲染元素:默认有 onClick 时为 button、否则 span */ + as?: 'span' | 'button'; + /** 语义色调 → statusbar-chip-ok / statusbar-chip-err */ + tone?: ChipTone; + /** 追加的专属类名(如 statusbar-pragent-chip / statusbar-llm-chip) */ + className?: string; + title?: string; + ariaLabel?: string; + ariaPressed?: boolean; + disabled?: boolean; + onClick?: () => void; + children: ReactNode; +} + +/** + * 状态栏 chip 通用壳:统一 `statusbar-chip` 基类 + 可选语义色调(ok/err),按是否可点 + * 渲染为 button / span,并透传 title / aria / disabled。各 chip 内部结构(图标 / 文案 / + * 下拉)作为 children 自管,专属样式经 className 追加。 + */ +export function StatusChip({ + as, + tone, + className, + title, + ariaLabel, + ariaPressed, + disabled, + onClick, + children, +}: StatusChipProps) { + const cls = ['statusbar-chip', tone && `statusbar-chip-${tone}`, className] + .filter(Boolean) + .join(' '); + const element = as ?? (onClick ? 'button' : 'span'); + if (element === 'button') { + return ( + + ); + } + return ( + + {children} + + ); +} diff --git a/apps/desktop/src/renderer/src/components/features/chat/statusbar/AutopilotChip.tsx b/apps/desktop/src/renderer/src/components/features/chat/statusbar/AutopilotChip.tsx new file mode 100644 index 0000000..e8bebbf --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/chat/statusbar/AutopilotChip.tsx @@ -0,0 +1,27 @@ +import { useTranslation } from 'react-i18next'; +import { RobotIcon, RobotOffIcon } from '../../../common/icons'; +import { StatusChip } from '../../../common/StatusChip'; + +/** + * AutoPilot 开关 chip:默认关,点击切换(持久化到 agent.autopilot.enabled,下次 poll 生效)。 + */ +export function AutopilotChip({ + enabled, + onToggle, +}: { + enabled: boolean; + onToggle: () => void; +}) { + const { t } = useTranslation(); + return ( + + {enabled ? : } + {t('statusBar.autopilot')} + + ); +} diff --git a/apps/desktop/src/renderer/src/components/features/chat/statusbar/PrAgentActiveChip.tsx b/apps/desktop/src/renderer/src/components/features/chat/statusbar/PrAgentActiveChip.tsx new file mode 100644 index 0000000..8b430a9 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/chat/statusbar/PrAgentActiveChip.tsx @@ -0,0 +1,210 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { invoke } from '../../../../api'; +import { useChatRunStore } from '../../../../stores/chat-run-store'; +import { formatElapsed } from '../../../../utils/time'; +import { StatusChip } from '../../../common/StatusChip'; + +/** + * 队列弹出菜单:状态栏 chip 上方弹出。先列全部运行中(active)行,再列 waiting 行。 + * waiting 行右侧 × 按钮取消。最多 6 个 item 高度,超出内部滚动。 + */ +function QueuePopover({ + active, + waiting, + onCancel, + onJumpToPr, +}: { + active: ReturnType['active']; + waiting: ReturnType['waiting']; + onCancel: (runId: string) => void; + onJumpToPr: (localId: string) => void; +}) { + const { t } = useTranslation(); + return ( +
+
+ {t('statusBar.queueHeader')} + + {t('statusBar.queueSummary', { running: active.length, waiting: waiting.length })} + +
+
    + {active.map((a) => ( +
  • +
  • + ))} + {waiting.map((q) => ( +
  • +
  • + ))} +
+
+ ); +} + +/** + * pr-agent 活动状态 chip:active 时显示运行中工具 + elapsed (可点跳 PR);idle 时 + * 显示"空闲"占位。PR 切换不会丢运行中状态,由 chatRunStore 跨实例维护。 + * + * 调用方应在 pr-agent 实际可用 (PrAgentStatus.available) 时才挂这条。 + */ +export function PrAgentActiveChip({ onJumpToPr }: { onJumpToPr?: (localId: string) => void }) { + const { t } = useTranslation(); + const { active, waiting } = useChatRunStore(); + // 并发模型:active 是运行中 run 列表。chip 主体展示第一条(primary)的 tool + elapsed, + // 多于一条时用徽标显示并发总数;点开 popover 列出全部运行中 + 排队中。 + const primary = active[0] ?? null; + const runningCount = active.length; + // 计时器:1s 粒度,跟 ChatPane 的 elapsed 同步。仅有 primary 时启 + const [elapsedMs, setElapsedMs] = useState(0); + // startedAt 入队时为 null,executeRun 起跑时设值;fallback 到 enqueuedAt 即可 + const startMs = primary ? new Date(primary.startedAt ?? primary.enqueuedAt).getTime() : 0; + useEffect(() => { + if (!primary) return; + setElapsedMs(Date.now() - startMs); + const id = setInterval(() => setElapsedMs(Date.now() - startMs), 1000); + return () => clearInterval(id); + // 仅依赖 primary runId + startMs:其它字段变化不影响计时 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [primary?.runId, startMs]); + + // 队列弹出菜单:点开 (active chip + 队列 ≥1) 显示 waiting 列表 + × 取消 + const [queueOpen, setQueueOpen] = useState(false); + const queueRef = useRef(null); + useEffect(() => { + if (!queueOpen) return; + const onDown = (e: MouseEvent): void => { + if (!queueRef.current?.contains(e.target as Node)) setQueueOpen(false); + }; + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') setQueueOpen(false); + }; + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [queueOpen]); + // 无可展开内容(无排队 且 运行中 ≤1)→ 自动收起菜单 + useEffect(() => { + if (queueOpen && waiting.length === 0 && active.length <= 1) setQueueOpen(false); + }, [queueOpen, waiting.length, active.length]); + + const handleCancelQueued = (runId: string): void => { + void invoke('pragent:cancel', { runId }); + }; + + if (!primary) { + // Idle:静态灰点 + "空闲" 文案。让用户一眼看到"agent 可用 + 当前没活儿" + return ( + + + ); + } + + // 可展开(运行中 >1 或有排队)→ chip 变 button 点开 popover;否则按 onJumpToPr 跳 PR。 + const expandable = waiting.length > 0 || runningCount > 1; + const clickable = expandable || Boolean(onJumpToPr); + const handleClick = (): void => { + if (expandable) { + setQueueOpen((v) => !v); + } else { + onJumpToPr?.(primary.prLocalId); + } + }; + // 徽标数 = 其它并发运行中(runningCount-1) + 排队中(waiting) + const extraCount = runningCount - 1 + waiting.length; + const title = expandable + ? t('statusBar.prAgentExpandableTitle', { running: runningCount, waiting: waiting.length }) + : t('statusBar.prAgentRunningTitle', { pr: primary.prLocalId, tool: primary.tool }) + + (clickable ? t('statusBar.jumpHint') : ''); + const inner = ( + <> +