feat: synchronize channel project settings

This commit is contained in:
mesalogo
2026-08-15 18:06:03 +08:00
parent 34bbfab2af
commit ed8d1791b1
11 changed files with 730 additions and 298 deletions
+232
View File
@@ -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(<App />)
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,
+2
View File
@@ -7493,8 +7493,10 @@ function App(): React.JSX.Element {
setRuntimeSettings(settings)
}}
onSetHeartbeatPaused={setHeartbeatPaused}
onUpdateProject={updateProject}
open
presentation="page"
projects={projects}
/>
</Suspense>
</RouteErrorBoundary>
@@ -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 (
<section
aria-label={t('channels.project.sectionAriaLabel', {
name: value.name
})}
className={`channel-project-settings channel-project-settings--${variant}`}
>
<div className="channel-project-settings__identity">
<span>{t('channels.project.identity')}</span>
<strong>{value.name}</strong>
</div>
<label className="field">
<span>{t('channels.project.descriptionLabel')}</span>
<textarea
aria-label={t('channels.project.descriptionAriaLabel', {
name: value.name
})}
autoFocus={autoFocus}
disabled={disabled}
maxLength={2_000}
onChange={(event) =>
onChange({ ...value, description: event.target.value })
}
rows={3}
value={value.description}
/>
</label>
<label className="field">
<span>{t('channels.project.rootLabel')}</span>
<div className="channel-project-settings__root">
<input
aria-label={t('channels.project.rootAriaLabel', {
name: value.name
})}
disabled={disabled}
maxLength={4_096}
onChange={(event) =>
onChange({ ...value, rootPath: event.target.value })
}
value={value.rootPath}
/>
<button
aria-label={t('channels.project.selectRootAriaLabel', {
name: value.name
})}
className="secondary-button"
disabled={disabled}
onClick={onSelectRoot}
type="button"
>
<FolderOpen aria-hidden="true" size={14} />
{t('channels.project.select')}
</button>
</div>
<small>{t('channels.project.rootHelp')}</small>
</label>
<label className="field">
<span>{t('channels.project.backendLabel')}</span>
<select
aria-label={t('channels.project.backendAriaLabel', {
name: value.name
})}
disabled={disabled}
onChange={(event) => {
const nextSelection = selectionByKey.get(event.target.value)
if (nextSelection) {
onChange({
...value,
runtimeSelection: nextSelection
})
}
}}
value={agentRuntimeSelectionKey(runtimeSelection)}
>
<optgroup label={t('channels.project.directModels')}>
{selectedDirectUnavailable && (
<option
disabled
value={agentRuntimeSelectionKey(runtimeSelection)}
>
{selectedDirectProfile
? t('channels.project.unavailableProfile', {
name: selectedDirectProfile.name,
modelName: selectedDirectProfile.modelName
})
: t('channels.project.missingProfile')}
</option>
)}
{directProfiles.length === 0 && (
<option disabled value="model:unavailable">
{t('channels.project.noTextModels')}
</option>
)}
{directProfiles.map((profile) => {
const selection = {
provider: 'model' as const,
profileId: profile.id
}
return (
<option
key={profile.id}
value={agentRuntimeSelectionKey(selection)}
>
{profile.name} · {profile.modelName}
</option>
)
})}
</optgroup>
<optgroup label="Agent Runtime">
<option value={agentRuntimeSelectionKey(openCodeSelection)}>
OpenCode
</option>
<option value={agentRuntimeSelectionKey(continueSelection)}>
Continue
</option>
<option
value={agentRuntimeSelectionKey(
deepseekHarnessSelection
)}
>
{t('channels.project.deepseekHarnessOption')}
</option>
</optgroup>
</select>
<small>
{runtimeSelectionDescription(
runtimeSelection,
runtimeSettings,
t
)}
</small>
</label>
<fieldset className="channel-work-mode">
<legend>{t('channels.project.defaultMode')}</legend>
<SegmentedControl
ariaLabel={t('channels.project.defaultModeAriaLabel', {
name: value.name
})}
disabled={disabled}
onChange={(defaultWorkMode) =>
onChange({ ...value, defaultWorkMode })
}
options={[
{ value: 'ask', label: t('channels.project.modes.ask') },
{
value: 'execute',
label: t('channels.project.modes.execute')
}
]}
value={value.defaultWorkMode}
/>
<small>{t('channels.project.overrideHelp')}</small>
</fieldset>
<p className="channel-project-settings__risk">
{value.defaultWorkMode === 'execute'
? t('channels.project.executeRisk')
: t('channels.project.askRisk')}{' '}
{t('channels.project.riskSuffix')}
</p>
</section>
)
}
@@ -6,6 +6,7 @@ import {
within,
waitFor
} from '@testing-library/react'
import type { ComponentProps } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ChannelSettingsSnapshot } from '../../shared/channel-settings-contracts'
import {
@@ -140,6 +141,23 @@ function settingsApi() {
}
}
function renderChannelSettings(
props: Pick<
ComponentProps<typeof ChannelSettingsSection>,
'initialChannel' | 'onNotify'
> = {}
) {
return render(
<ChannelSettingsSection
{...props}
onUpdateProject={(projectId, input) =>
window.goodbuddy.projects.update(projectId, input)
}
projectList={projects}
/>
)
}
afterEach(async () => {
cleanup()
vi.restoreAllMocks()
@@ -188,7 +206,7 @@ describe('ChannelSettingsSection', () => {
})
const onNotify = vi.fn()
render(<ChannelSettingsSection onNotify={onNotify} />)
renderChannelSettings({ onNotify })
fireEvent.click(
await screen.findByRole('tab', { name: '企业微信' })
)
@@ -200,6 +218,9 @@ describe('ChannelSettingsSection', () => {
fireEvent.change(screen.getByLabelText('企业微信机器人 ID'), {
target: { value: 'bot-1' }
})
fireEvent.change(screen.getByLabelText('企业微信 项目说明'), {
target: { value: '企业微信同步项目' }
})
fireEvent.change(screen.getByLabelText('企业微信Secret'), {
target: { value: 'channel-secret' }
})
@@ -235,6 +256,7 @@ describe('ChannelSettingsSection', () => {
expect(updateProject).toHaveBeenCalledWith(
projects[1]!.id,
expect.objectContaining({
description: '企业微信同步项目',
rootPath: 'C:\\RemoteWorkspace',
defaultWorkMode: 'execute',
runtimeSelection: {
@@ -289,7 +311,7 @@ describe('ChannelSettingsSection', () => {
})
const onNotify = vi.fn()
render(<ChannelSettingsSection onNotify={onNotify} />)
renderChannelSettings({ onNotify })
fireEvent.click(
await screen.findByRole('tab', { name: '钉钉' })
)
@@ -329,7 +351,7 @@ describe('ChannelSettingsSection', () => {
} as unknown as DesktopApi
})
render(<ChannelSettingsSection />)
renderChannelSettings()
const trigger = await screen.findByRole('button', {
name: '扫码绑定'
})
@@ -380,7 +402,7 @@ describe('ChannelSettingsSection', () => {
} as unknown as DesktopApi
})
render(<ChannelSettingsSection />)
renderChannelSettings()
const disconnect = await screen.findByRole('button', {
name: '断开本机绑定'
})
@@ -420,7 +442,7 @@ describe('ChannelSettingsSection', () => {
} as unknown as DesktopApi
})
render(<ChannelSettingsSection />)
renderChannelSettings()
fireEvent.click(
await screen.findByRole('button', { name: '扫码绑定' })
)
@@ -465,7 +487,7 @@ describe('ChannelSettingsSection', () => {
} as unknown as DesktopApi
})
render(<ChannelSettingsSection />)
renderChannelSettings()
const backend = await screen.findByLabelText(
'微信 ClawBot 消息处理后端'
)
@@ -552,7 +574,7 @@ describe('ChannelSettingsSection', () => {
} as unknown as DesktopApi
})
render(<ChannelSettingsSection />)
renderChannelSettings()
fireEvent.change(
await screen.findByLabelText('微信 ClawBot 默认工作目录'),
{ target: { value: '' } }
@@ -586,7 +608,7 @@ describe('ChannelSettingsSection', () => {
} as unknown as DesktopApi
})
render(<ChannelSettingsSection />)
renderChannelSettings()
const tablist = await screen.findByRole('tablist', {
name: '消息通道配置'
})
@@ -604,6 +626,10 @@ describe('ChannelSettingsSection', () => {
expect(weixinTab).toHaveAttribute('aria-selected', 'true')
expect(wecomTab).toHaveAttribute('tabindex', '-1')
expect(dingtalkTab).toHaveAttribute('tabindex', '-1')
expect(screen.getByText('项目设置')).toBeInTheDocument()
expect(
screen.getByText('与左上角当前通道项目的设置保持同步。')
).toBeInTheDocument()
expect(
screen.queryByRole('switch', { name: '启用企业微信通道' })
).not.toBeInTheDocument()
@@ -639,7 +665,7 @@ describe('ChannelSettingsSection', () => {
} as unknown as DesktopApi
})
render(<ChannelSettingsSection initialChannel="wecom" />)
renderChannelSettings({ initialChannel: 'wecom' })
expect(
await screen.findByRole('tab', { name: '企业微信' })
@@ -669,7 +695,7 @@ describe('ChannelSettingsSection', () => {
})
await i18n.changeLanguage('en-US')
render(<ChannelSettingsSection />)
renderChannelSettings()
expect(
await screen.findByRole('tablist', {
+104 -278
View File
@@ -1,11 +1,9 @@
import {
FlaskConical,
FolderOpen,
Save,
Smartphone,
Unplug
} from 'lucide-react'
import type { TFunction } from 'i18next'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import QRCode from 'qrcode'
@@ -22,23 +20,21 @@ import {
normalizeInteractiveWorkMode,
projectChannels,
type AssistantProject,
type InteractiveWorkMode,
type ProjectCreateInput,
type ProjectChannel
} from '../../shared/assistant-contracts'
import {
agentRuntimeSelectionKey,
isChannelModelProfileUsable,
repairChannelRuntimeSelection,
type AgentRuntimeSelection
} from '../../shared/runtime-selection-contracts'
import type { WeixinBindingSnapshot } from '../../shared/weixin-channel-contracts'
import type { AppNotificationInput } from './notifications'
import { trapTabFocus } from './dialog-focus'
import { PageTabs, SegmentedControl } from './WorkspacePrimitives'
import { PageTabs } from './WorkspacePrimitives'
import {
SettingsCategoryHeader,
SettingsWarningList
} from './SettingsPrimitives'
import {
channelProjectDraft,
ChannelProjectSettingsFields
} from './ChannelProjectSettingsFields'
type ChannelDraft = {
enabled: boolean
@@ -49,13 +45,13 @@ type ChannelDraft = {
allowGroupMessages: boolean
}
type ChannelProjectDraft = {
type ChannelProjectDraft = ProjectCreateInput & {
id: string
name: string
description: string
rootPath: string
defaultWorkMode: InteractiveWorkMode
runtimeSelection: AgentRuntimeSelection
}
type ChannelProjectOverride = {
sourceKey: string
value: ChannelProjectDraft
}
const channelOrder: readonly ProjectChannel[] = projectChannels
@@ -172,14 +168,16 @@ function projectDraftsFrom(
project.channel,
{
id: project.id,
name: project.name,
description: project.description,
rootPath: project.rootPath,
defaultWorkMode: normalizeInteractiveWorkMode(
project.defaultWorkMode
),
runtimeSelection: repairChannelRuntimeSelection(
project.runtimeSelection ?? { provider: 'auto' },
...channelProjectDraft(
{
name: project.name,
description: project.description,
rootPath: project.rootPath,
defaultWorkMode: normalizeInteractiveWorkMode(
project.defaultWorkMode
),
runtimeSelection: project.runtimeSelection
},
runtimeSettings
)
}
@@ -187,248 +185,42 @@ function projectDraftsFrom(
)
}
function usableChannelModelProfiles(
settings: RuntimeSettings
): RuntimeSettings['modelProfiles'] {
return settings.modelProfiles.filter(
isChannelModelProfileUsable
)
}
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
function projectDraftKey(project: ChannelProjectDraft): string {
return JSON.stringify({
description: project.description,
rootPath: project.rootPath,
defaultWorkMode: project.defaultWorkMode,
runtimeSelection: project.runtimeSelection
})
}
function ChannelProjectControls({
draft,
function ChannelProjectCard({
onChange,
onSelectRoot,
project,
runtimeSettings
}: {
draft: ChannelProjectDraft
onChange: (draft: ChannelProjectDraft) => void
onChange: (next: ChannelProjectDraft) => void
onSelectRoot: () => void
project: ChannelProjectDraft
runtimeSettings: RuntimeSettings
}): React.JSX.Element {
const { t } = useTranslation('integrations')
const openCodeSelection = configuredRuntimeSelection(
'opencode'
)
const continueSelection = configuredRuntimeSelection(
'continue'
)
const deepseekHarnessSelection = configuredRuntimeSelection(
'deepseek-harness'
)
const directProfiles = usableChannelModelProfiles(runtimeSettings)
const selectedDirectProfileId =
draft.runtimeSelection.provider === 'model'
? draft.runtimeSelection.profileId
: undefined
const selectedDirectProfile = runtimeSettings.modelProfiles.find(
(profile) => profile.id === selectedDirectProfileId
)
const selectedDirectUnavailable =
selectedDirectProfileId !== undefined &&
!directProfiles.some(
(profile) => profile.id === selectedDirectProfileId
)
const selections = [
...directProfiles.map((profile) => ({
provider: 'model' as const,
profileId: profile.id
})),
openCodeSelection,
continueSelection,
deepseekHarnessSelection
]
const selectionByKey = new Map(
selections.map((selection) => [
agentRuntimeSelectionKey(selection),
selection
])
)
return (
<section
aria-label={t('channels.project.sectionAriaLabel', {
name: draft.name
})}
className="channel-project-settings"
>
<div className="channel-project-settings__identity">
<span>{t('channels.project.identity')}</span>
<strong>{draft.name}</strong>
</div>
<label className="field">
<span>{t('channels.project.rootLabel')}</span>
<div className="channel-project-settings__root">
<input
aria-label={t('channels.project.rootAriaLabel', {
name: draft.name
})}
maxLength={4_096}
onChange={(event) =>
onChange({ ...draft, rootPath: event.target.value })
}
value={draft.rootPath}
/>
<button
aria-label={t('channels.project.selectRootAriaLabel', {
name: draft.name
})}
className="secondary-button"
onClick={onSelectRoot}
type="button"
>
<FolderOpen aria-hidden="true" size={14} />
{t('channels.project.select')}
</button>
<article className="capability-card channel-settings-card">
<div className="capability-card__header">
<div>
<strong>{t('channels.project.cardTitle')}</strong>
<small>{t('channels.project.cardDescription')}</small>
</div>
<small>{t('channels.project.rootHelp')}</small>
</label>
<label className="field">
<span>{t('channels.project.backendLabel')}</span>
<select
aria-label={t('channels.project.backendAriaLabel', {
name: draft.name
})}
onChange={(event) => {
const runtimeSelection = selectionByKey.get(
event.target.value
)
if (runtimeSelection) {
onChange({ ...draft, runtimeSelection })
}
}}
value={agentRuntimeSelectionKey(draft.runtimeSelection)}
>
<optgroup label={t('channels.project.directModels')}>
{selectedDirectUnavailable && (
<option
disabled
value={agentRuntimeSelectionKey(draft.runtimeSelection)}
>
{selectedDirectProfile
? t('channels.project.unavailableProfile', {
name: selectedDirectProfile.name,
modelName: selectedDirectProfile.modelName
})
: t('channels.project.missingProfile')}
</option>
)}
{directProfiles.length === 0 && (
<option disabled value="model:unavailable">
{t('channels.project.noTextModels')}
</option>
)}
{directProfiles.map((profile) => {
const selection = {
provider: 'model' as const,
profileId: profile.id
}
return (
<option
key={profile.id}
value={agentRuntimeSelectionKey(selection)}
>
{profile.name} · {profile.modelName}
</option>
)
})}
</optgroup>
<optgroup label="Agent Runtime">
<option value={agentRuntimeSelectionKey(openCodeSelection)}>
OpenCode
</option>
<option value={agentRuntimeSelectionKey(continueSelection)}>
Continue
</option>
<option
value={agentRuntimeSelectionKey(deepseekHarnessSelection)}
>
{t('channels.project.deepseekHarnessOption')}
</option>
</optgroup>
</select>
<small>
{runtimeSelectionDescription(
draft.runtimeSelection,
runtimeSettings,
t
)}
</small>
</label>
<fieldset className="channel-work-mode">
<legend>{t('channels.project.defaultMode')}</legend>
<SegmentedControl
ariaLabel={t('channels.project.defaultModeAriaLabel', {
name: draft.name
})}
onChange={(defaultWorkMode) =>
onChange({ ...draft, defaultWorkMode })
}
options={[
{ value: 'ask', label: t('channels.project.modes.ask') },
{
value: 'execute',
label: t('channels.project.modes.execute')
}
]}
value={draft.defaultWorkMode}
/>
<small>
{t('channels.project.overrideHelp')}
</small>
</fieldset>
<p className="channel-project-settings__risk">
{draft.defaultWorkMode === 'execute'
? t('channels.project.executeRisk')
: t('channels.project.askRisk')}{' '}
{t('channels.project.riskSuffix')}
</p>
</section>
</div>
<ChannelProjectSettingsFields
onChange={(next) => onChange({ ...next, id: project.id })}
onSelectRoot={onSelectRoot}
runtimeSettings={runtimeSettings}
value={project}
/>
</article>
)
}
@@ -464,6 +256,7 @@ function ChannelEditor({
const prefix = `channel-${channel}`
return (
<>
<article className="capability-card channel-settings-card">
<div className="capability-card__header">
<div>
@@ -603,13 +396,6 @@ function ChannelEditor({
<span>{t('channels.credential.groupMessages')}</span>
</label>
<ChannelProjectControls
draft={project}
onChange={onProjectChange}
onSelectRoot={onSelectRoot}
runtimeSettings={runtimeSettings}
/>
<button
className="secondary-button"
disabled={testing}
@@ -624,6 +410,13 @@ function ChannelEditor({
})}
</button>
</article>
<ChannelProjectCard
onChange={onProjectChange}
onSelectRoot={onSelectRoot}
project={project}
runtimeSettings={runtimeSettings}
/>
</>
)
}
@@ -963,13 +756,13 @@ function WeixinChannelEditor({
{t('channels.weixin.behaviorHelp')}
</small>
<ChannelProjectControls
draft={project}
onChange={onProjectChange}
onSelectRoot={onSelectRoot}
runtimeSettings={runtimeSettings}
/>
</article>
<ChannelProjectCard
onChange={onProjectChange}
onSelectRoot={onSelectRoot}
project={project}
runtimeSettings={runtimeSettings}
/>
{bindingOpen && (
<WeixinQrDialog
binding={binding}
@@ -986,10 +779,17 @@ function WeixinChannelEditor({
export function ChannelSettingsSection({
initialChannel = 'weixin',
onNotify = () => undefined
onNotify = () => undefined,
onUpdateProject,
projectList
}: {
initialChannel?: ProjectChannel
onNotify?: (notification: AppNotificationInput) => void
onUpdateProject: (
projectId: string,
input: ProjectCreateInput
) => Promise<AssistantProject>
projectList: AssistantProject[]
}): React.JSX.Element {
const { t } = useTranslation('integrations')
const tRef = useRef(t)
@@ -1004,8 +804,8 @@ export function ChannelSettingsSection({
const [snapshot, setSnapshot] = useState<ChannelSettingsSnapshot>()
const [runtimeSettings, setRuntimeSettings] =
useState<RuntimeSettings>()
const [projects, setProjects] = useState<
Partial<Record<ProjectChannel, ChannelProjectDraft>>
const [projectOverrides, setProjectOverrides] = useState<
Partial<Record<ProjectChannel, ChannelProjectOverride>>
>({})
const [weixinEnabled, setWeixinEnabled] = useState(false)
const [binding, setBinding] = useState<WeixinBindingSnapshot>({
@@ -1049,18 +849,14 @@ export function ChannelSettingsSection({
}
return Promise.all([
api.getSnapshot(),
window.goodbuddy.projects.list(false),
api.getWeixinBinding(),
window.goodbuddy.settings.getRuntime()
])
})()
.then(([next, projectList, bindingSnapshot, nextRuntimeSettings]) => {
.then(([next, bindingSnapshot, nextRuntimeSettings]) => {
if (active) {
applySnapshot(next)
setRuntimeSettings(nextRuntimeSettings)
setProjects(
projectDraftsFrom(projectList, nextRuntimeSettings)
)
setBinding(bindingSnapshot)
}
})
@@ -1090,6 +886,27 @@ export function ChannelSettingsSection({
}
}, [closeBinding])
const persistedProjects = runtimeSettings
? projectDraftsFrom(projectList, runtimeSettings)
: {}
const projects = Object.fromEntries(
channelOrder.flatMap((channel) => {
const persisted = persistedProjects[channel]
if (!persisted) {
return []
}
const override = projectOverrides[channel]
return [
[
channel,
override?.sourceKey === projectDraftKey(persisted)
? override.value
: persisted
]
]
})
) as Partial<Record<ProjectChannel, ChannelProjectDraft>>
const save = async (): Promise<void> => {
const api = window.goodbuddy.channels
if (!api || !snapshot || !runtimeSettings) {
@@ -1132,9 +949,9 @@ export function ChannelSettingsSection({
setError(undefined)
setBindingError(undefined)
try {
const updatedProjects = await Promise.all(
await Promise.all(
channelProjects.map((project) =>
window.goodbuddy.projects.update(project!.id, {
onUpdateProject(project!.id, {
name: project!.name,
description: project!.description,
rootPath: project!.rootPath,
@@ -1143,7 +960,6 @@ export function ChannelSettingsSection({
})
)
)
setProjects(projectDraftsFrom(updatedProjects, runtimeSettings))
if (Object.keys(input).length > 0) {
applySnapshot(await api.apply(input))
}
@@ -1165,7 +981,17 @@ export function ChannelSettingsSection({
channel: ProjectChannel,
next: ChannelProjectDraft
): void => {
setProjects((current) => ({ ...current, [channel]: next }))
const persisted = persistedProjects[channel]
if (!persisted) {
return
}
setProjectOverrides((current) => ({
...current,
[channel]: {
sourceKey: projectDraftKey(persisted),
value: next
}
}))
}
const selectRoot = async (
+29 -9
View File
@@ -23,6 +23,10 @@ import {
getDefaultRuntimeSelection,
getRuntimeSelectionForProvider
} from './runtime-selection'
import {
channelProjectDraft,
ChannelProjectSettingsFields
} from './ChannelProjectSettingsFields'
type ProjectSwitcherProps = {
projects: AssistantProject[]
@@ -264,7 +268,7 @@ export function ProjectSwitcher({
setError(undefined)
setConfirmingDelete(false)
setDeleteConfirmation('')
setDraft({
const nextDraft: ProjectCreateInput = {
name: activeProject.name,
description: activeProject.description,
rootPath: activeProject.rootPath,
@@ -276,7 +280,12 @@ export function ProjectSwitcher({
(runtimeSettings
? getDefaultRuntimeSelection(runtimeSettings)
: undefined)
})
}
setDraft(
activeProject.kind === 'channel' && runtimeSettings
? channelProjectDraft(nextDraft, runtimeSettings)
: nextDraft
)
restoreFocusTarget.current = 'settings'
setDialogMode('settings')
}}
@@ -322,11 +331,25 @@ export function ProjectSwitcher({
<X size={14} />
</button>
</header>
<label>
{dialogMode === 'settings' &&
activeProject?.kind === 'channel' &&
runtimeSettings ? (
<ChannelProjectSettingsFields
autoFocus
disabled={busy}
onChange={setDraft}
onSelectRoot={() => void selectRoot()}
runtimeSettings={runtimeSettings}
value={draft}
variant="dialog"
/>
) : (
<>
<label>
<span>{t('projectSwitcher.dialog.fields.name')}</span>
<input
autoFocus={!confirmingDelete}
disabled={busy || activeProject?.kind === 'channel'}
disabled={busy}
maxLength={120}
onChange={(event) =>
setDraft((current) => ({
@@ -336,11 +359,6 @@ export function ProjectSwitcher({
}
value={draft.name}
/>
{activeProject?.kind === 'channel' && (
<small>
{t('projectSwitcher.dialog.channelManaged')}
</small>
)}
</label>
<label>
<span>
@@ -436,6 +454,8 @@ export function ProjectSwitcher({
{t('projectSwitcher.dialog.defaultRuntimeHelp')}
</small>
</label>
)}
</>
)}
{error && (
<p className="project-create-card__error" role="alert">
+5 -1
View File
@@ -282,7 +282,11 @@ const heartbeatSettingsProps = {
onCreateHeartbeat: vi.fn(async () => {}),
onSetHeartbeatPaused: vi.fn(async () => {}),
onRemoveHeartbeat: vi.fn(async () => {}),
onRunHeartbeat: vi.fn(async () => {})
onRunHeartbeat: vi.fn(async () => {}),
onUpdateProject: vi.fn(async () => {
throw new Error('Project update is not used in this test')
}),
projects: []
}
const assistantExpert: AssistantExpert = {
id: '00000000-0000-4000-8000-000000000101',
+11
View File
@@ -14,7 +14,9 @@ import { useTranslation } from 'react-i18next'
import type {
AssistantExpert,
AssistantHeartbeatConfig,
AssistantProject,
HeartbeatCreateInput,
ProjectCreateInput,
ProjectChannel
} from '../../shared/assistant-contracts'
import type {
@@ -78,6 +80,11 @@ type SettingsPanelProps = {
onClose: () => void
onSaved: (settings: RuntimeSettings) => void
onNotify?: (notification: AppNotificationInput) => void
onUpdateProject: (
projectId: string,
input: ProjectCreateInput
) => Promise<AssistantProject>
projects: AssistantProject[]
onExpertsChanged?: (experts: AssistantExpert[]) => void
onClearLocalData: () => Promise<void>
heartbeats: AssistantHeartbeatConfig[]
@@ -431,6 +438,8 @@ export function SettingsPanel({
onClose,
onSaved,
onNotify = () => {},
onUpdateProject,
projects,
onClearLocalData,
heartbeats,
onCreateHeartbeat,
@@ -2824,6 +2833,8 @@ export function SettingsPanel({
<ChannelSettingsSection
initialChannel={initialChannel}
onNotify={onNotify}
onUpdateProject={onUpdateProject}
projectList={projects}
/>
)}
{activeTab === 'roles' && (
@@ -18,6 +18,11 @@ export const integrations = {
project: {
sectionAriaLabel: '{{name}} channel project settings',
identity: 'Channel project',
cardTitle: 'Project settings',
cardDescription:
'Stays synchronized with the selected channel project in the top-left project switcher.',
descriptionLabel: 'Project description',
descriptionAriaLabel: '{{name}} project description',
rootLabel: 'Default working directory',
rootAriaLabel: '{{name}} default working directory',
selectRootAriaLabel:
@@ -15,6 +15,11 @@ export const integrations = {
project: {
sectionAriaLabel: '{{name}} 通道项目设置',
identity: '通道项目',
cardTitle: '项目设置',
cardDescription:
'与左上角当前通道项目的设置保持同步。',
descriptionLabel: '项目说明',
descriptionAriaLabel: '{{name}} 项目说明',
rootLabel: '默认工作目录',
rootAriaLabel: '{{name}} 默认工作目录',
selectRootAriaLabel: '选择 {{name}} 默认工作目录',
+7
View File
@@ -5354,7 +5354,9 @@ details.settings-section > :not(summary) + :not(summary) {
}
.channel-settings__panel {
display: grid;
min-width: 0;
gap: var(--space-4);
}
.channel-settings__panel > .channel-settings-card {
@@ -5368,6 +5370,11 @@ details.settings-section > :not(summary) + :not(summary) {
gap: var(--space-3);
}
.channel-project-settings--dialog {
padding-top: 0;
border-top: 0;
}
.channel-project-settings__identity {
display: flex;
align-items: center;