From ed8d1791b1fe20e104a6ca22177ccfb3956f74c3 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sat, 15 Aug 2026 00:03:36 +0800 Subject: [PATCH] feat: synchronize channel project settings --- src/renderer/src/App.test.tsx | 232 +++++++++++ src/renderer/src/App.tsx | 2 + .../src/ChannelProjectSettingsFields.tsx | 294 ++++++++++++++ .../src/ChannelSettingsSection.test.tsx | 46 ++- src/renderer/src/ChannelSettingsSection.tsx | 382 +++++------------- src/renderer/src/ProjectSwitcher.tsx | 38 +- src/renderer/src/SettingsPanel.test.tsx | 6 +- src/renderer/src/SettingsPanel.tsx | 11 + .../src/i18n/locales/en-US/integrations.ts | 5 + .../src/i18n/locales/zh-CN/integrations.ts | 5 + src/renderer/src/styles.css | 7 + 11 files changed, 730 insertions(+), 298 deletions(-) create mode 100644 src/renderer/src/ChannelProjectSettingsFields.tsx diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 70e4fc7..7887b8c 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -15,6 +15,8 @@ import type { DesktopApi } from '../../shared/contracts' import type { ApplicationSettings } from '../../shared/application-settings-contracts' +import type { AssistantProject } from '../../shared/assistant-contracts' +import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts' const speechRecognitionMocks = vi.hoisted(() => ({ startPcmRecording: vi.fn() @@ -696,6 +698,7 @@ describe('App', () => { delete document.documentElement.dataset.theme document.documentElement.style.colorScheme = '' vi.clearAllMocks() + api.channels = undefined vi.mocked(api.conversations.list).mockReset().mockResolvedValue([]) vi.mocked(api.conversations.replace) .mockReset() @@ -3298,6 +3301,235 @@ describe('App', () => { expect(channelSettingsTab).toHaveAttribute('aria-selected', 'true') }) + it('keeps channel project settings synchronized between both entry points', async () => { + let channelProjects: AssistantProject[] = [ + ['weixin', '微信 ClawBot'], + ['wecom', '企业微信'], + ['dingtalk', '钉钉'] + ].map(([channel, name], index) => ({ + ...project, + id: `00000000-0000-4000-8000-00000000020${index + 1}`, + name: name!, + description: `${name}通道项目`, + kind: 'channel' as const, + channel: channel as 'weixin' | 'wecom' | 'dingtalk', + runtimeSelection: { + provider: 'model' as const, + profileId: modelProfileId + } + })) + vi.mocked(api.projects.list).mockResolvedValueOnce([ + project, + ...channelProjects + ]) + vi.mocked(api.projects.update).mockImplementation( + async (projectToUpdate, input) => { + const existing = channelProjects.find( + (candidate) => candidate.id === projectToUpdate + ) + if (!existing) { + return { + ...project, + ...input, + id: projectToUpdate + } + } + const updated = { + ...existing, + ...input, + updatedAt: '2026-08-14T00:00:00.000Z' + } + channelProjects = channelProjects.map((candidate) => + candidate.id === updated.id ? updated : candidate + ) + return updated + } + ) + api.channels = { + getSnapshot: vi.fn(async () => ({ + weixin: { + enabled: false, + bindingConfigured: false, + source: 'none' as const, + status: { state: 'disabled' as const } + }, + wecom: { + enabled: false, + botId: '', + secretConfigured: false, + source: 'none' as const, + readOnly: false, + allowedSenderIds: [], + allowGroupMessages: false, + status: { state: 'disabled' as const } + }, + dingtalk: { + enabled: false, + clientId: '', + secretConfigured: false, + source: 'none' as const, + readOnly: false, + allowedSenderIds: [], + allowGroupMessages: false, + status: { state: 'disabled' as const } + } + })), + apply: vi.fn(async () => { + throw new Error('No channel connection settings changed') + }), + testConnection: vi.fn(async (channel) => ({ + channel, + ok: true + })), + getWeixinBinding: vi.fn(async () => ({ + status: 'stopped' as const + })), + startWeixinBinding: vi.fn(async () => ({ + status: 'starting' as const + })), + submitWeixinVerification: vi.fn(async () => ({ + status: 'scanned' as const + })), + disconnectWeixin: vi.fn(async () => ({ + status: 'stopped' as const + })), + onWeixinBindingChanged: vi.fn(() => () => undefined), + onRemoteActivity: vi.fn(() => () => undefined) + } + + render() + const weixinProject = channelProjects[0]! + fireEvent.change(await screen.findByLabelText('当前项目'), { + target: { value: weixinProject.id } + }) + fireEvent.click( + await screen.findByRole('button', { name: '打开设置' }) + ) + + expect( + await screen.findByLabelText('微信 ClawBot 项目说明') + ).toHaveValue('微信 ClawBot通道项目') + + fireEvent.click(screen.getByLabelText('项目设置')) + let dialog = screen.getByRole('dialog', { name: '项目设置' }) + const dialogDescription = within(dialog).getByLabelText( + '微信 ClawBot 项目说明' + ) + expect(dialogDescription).toHaveFocus() + const dialogBackend = within(dialog).getByLabelText( + '微信 ClawBot 消息处理后端' + ) + expect( + within(dialogBackend).getByRole('option', { + name: 'DeepSeek Harness(预览 · OpenAI 兼容)' + }) + ).toBeInTheDocument() + fireEvent.change( + dialogDescription, + { target: { value: '从左上角更新' } } + ) + fireEvent.change( + within(dialog).getByLabelText('微信 ClawBot 默认工作目录'), + { target: { value: 'C:\\FromSwitcher' } } + ) + fireEvent.change( + dialogBackend, + { + target: { + value: agentRuntimeSelectionKey({ + provider: 'opencode' + }) + } + } + ) + fireEvent.click( + within( + within(dialog).getByRole('group', { + name: '微信 ClawBot 默认模式' + }) + ).getByRole('button', { name: '执行' }) + ) + fireEvent.click( + within(dialog).getByRole('button', { name: '保存项目' }) + ) + + await waitFor(() => { + expect( + screen.getByLabelText('微信 ClawBot 项目说明') + ).toHaveValue('从左上角更新') + expect( + screen.getByLabelText('微信 ClawBot 默认工作目录') + ).toHaveValue('C:\\FromSwitcher') + expect( + screen.getByLabelText('微信 ClawBot 消息处理后端') + ).toHaveValue( + agentRuntimeSelectionKey({ provider: 'opencode' }) + ) + }) + + fireEvent.change( + screen.getByLabelText('微信 ClawBot 项目说明'), + { target: { value: '从消息通道更新' } } + ) + fireEvent.change( + screen.getByLabelText('微信 ClawBot 默认工作目录'), + { target: { value: 'C:\\FromChannels' } } + ) + fireEvent.change( + screen.getByLabelText('微信 ClawBot 消息处理后端'), + { + target: { + value: agentRuntimeSelectionKey({ + provider: 'continue' + }) + } + } + ) + fireEvent.click( + within( + screen.getByRole('group', { + name: '微信 ClawBot 默认模式' + }) + ).getByRole('button', { name: '对话' }) + ) + fireEvent.click( + screen.getByRole('button', { name: '保存通道设置' }) + ) + + await waitFor(() => + expect(api.projects.update).toHaveBeenCalledWith( + weixinProject.id, + expect.objectContaining({ + description: '从消息通道更新', + rootPath: 'C:\\FromChannels', + defaultWorkMode: 'ask', + runtimeSelection: { provider: 'continue' } + }) + ) + ) + + fireEvent.click(screen.getByLabelText('项目设置')) + dialog = screen.getByRole('dialog', { name: '项目设置' }) + expect( + within(dialog).getByLabelText('微信 ClawBot 项目说明') + ).toHaveValue('从消息通道更新') + expect( + within(dialog).getByLabelText('微信 ClawBot 默认工作目录') + ).toHaveValue('C:\\FromChannels') + expect( + within(dialog).getByLabelText('微信 ClawBot 消息处理后端') + ).toHaveValue( + agentRuntimeSelectionKey({ provider: 'continue' }) + ) + expect( + within( + within(dialog).getByRole('group', { + name: '微信 ClawBot 默认模式' + }) + ).getByRole('button', { name: '对话' }) + ).toHaveAttribute('aria-pressed', 'true') + }) + it('shows client-created remote conversations without obsolete approval copy', async () => { const channelProject = { ...project, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 3397881..46809e8 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -7493,8 +7493,10 @@ function App(): React.JSX.Element { setRuntimeSettings(settings) }} onSetHeartbeatPaused={setHeartbeatPaused} + onUpdateProject={updateProject} open presentation="page" + projects={projects} /> diff --git a/src/renderer/src/ChannelProjectSettingsFields.tsx b/src/renderer/src/ChannelProjectSettingsFields.tsx new file mode 100644 index 0000000..3bb1bad --- /dev/null +++ b/src/renderer/src/ChannelProjectSettingsFields.tsx @@ -0,0 +1,294 @@ +import { FolderOpen } from 'lucide-react' +import type { TFunction } from 'i18next' +import { useTranslation } from 'react-i18next' +import type { ProjectCreateInput } from '../../shared/assistant-contracts' +import type { RuntimeSettings } from '../../shared/contracts' +import { + agentRuntimeSelectionKey, + isChannelModelProfileUsable, + repairChannelRuntimeSelection, + type AgentRuntimeSelection +} from '../../shared/runtime-selection-contracts' +import { SegmentedControl } from './WorkspacePrimitives' + +function configuredRuntimeSelection( + provider: 'opencode' | 'continue' | 'deepseek-harness' +): AgentRuntimeSelection { + return { provider } +} + +function runtimeSelectionDescription( + selection: AgentRuntimeSelection, + settings: RuntimeSettings, + t: TFunction<'integrations'> +): string { + if (selection.provider === 'model') { + const profile = settings.modelProfiles.find( + (candidate) => candidate.id === selection.profileId + ) + if (!profile) { + return t('channels.project.missingSelection') + } + if (profile.protocol === 'openai-images-generations') { + return t('channels.project.imageOnlySelection') + } + if ( + profile.authentication === 'api-key' && + !profile.apiKeyConfigured + ) { + return t('channels.project.missingCredential') + } + return t('channels.project.directDescription', { + name: profile.name, + modelName: profile.modelName + }) + } + if (selection.provider === 'auto') { + return t('channels.project.automaticDescription') + } + const runtimeLabel = + selection.provider === 'opencode' + ? 'OpenCode' + : selection.provider === 'continue' + ? 'Continue' + : 'DeepSeek Harness' + return t('channels.project.runtimeDescription', { + runtime: runtimeLabel + }) +} + +export function channelProjectDraft( + project: ProjectCreateInput, + runtimeSettings: RuntimeSettings +): ProjectCreateInput { + return { + name: project.name, + description: project.description, + rootPath: project.rootPath, + defaultWorkMode: project.defaultWorkMode, + runtimeSelection: repairChannelRuntimeSelection( + project.runtimeSelection ?? { provider: 'auto' }, + runtimeSettings + ) + } +} + +export function ChannelProjectSettingsFields({ + autoFocus = false, + disabled = false, + onChange, + onSelectRoot, + runtimeSettings, + value, + variant = 'card' +}: { + autoFocus?: boolean + disabled?: boolean + onChange: (value: ProjectCreateInput) => void + onSelectRoot: () => void + runtimeSettings: RuntimeSettings + value: ProjectCreateInput + variant?: 'card' | 'dialog' +}): React.JSX.Element { + const { t } = useTranslation('integrations') + const runtimeSelection = repairChannelRuntimeSelection( + value.runtimeSelection ?? { provider: 'auto' }, + runtimeSettings + ) + const directProfiles = runtimeSettings.modelProfiles.filter( + isChannelModelProfileUsable + ) + const selectedDirectProfileId = + runtimeSelection.provider === 'model' + ? runtimeSelection.profileId + : undefined + const selectedDirectProfile = runtimeSettings.modelProfiles.find( + (profile) => profile.id === selectedDirectProfileId + ) + const selectedDirectUnavailable = + selectedDirectProfileId !== undefined && + !directProfiles.some( + (profile) => profile.id === selectedDirectProfileId + ) + const openCodeSelection = configuredRuntimeSelection('opencode') + const continueSelection = configuredRuntimeSelection('continue') + const deepseekHarnessSelection = + configuredRuntimeSelection('deepseek-harness') + const selections: AgentRuntimeSelection[] = [ + ...directProfiles.map((profile) => ({ + provider: 'model' as const, + profileId: profile.id + })), + openCodeSelection, + continueSelection, + deepseekHarnessSelection + ] + const selectionByKey = new Map( + selections.map((selection) => [ + agentRuntimeSelectionKey(selection), + selection + ]) + ) + + return ( +
+
+ {t('channels.project.identity')} + {value.name} +
+