feat: synchronize channel project settings
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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,
|
||||
...channelProjectDraft(
|
||||
{
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
rootPath: project.rootPath,
|
||||
defaultWorkMode: normalizeInteractiveWorkMode(
|
||||
project.defaultWorkMode
|
||||
),
|
||||
runtimeSelection: repairChannelRuntimeSelection(
|
||||
project.runtimeSelection ?? { provider: 'auto' },
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
</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}
|
||||
<ChannelProjectSettingsFields
|
||||
onChange={(next) => onChange({ ...next, id: project.id })}
|
||||
onSelectRoot={onSelectRoot}
|
||||
runtimeSettings={runtimeSettings}
|
||||
value={project}
|
||||
/>
|
||||
<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>
|
||||
</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}
|
||||
</article>
|
||||
<ChannelProjectCard
|
||||
onChange={onProjectChange}
|
||||
onSelectRoot={onSelectRoot}
|
||||
project={project}
|
||||
runtimeSettings={runtimeSettings}
|
||||
/>
|
||||
</article>
|
||||
{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 (
|
||||
|
||||
@@ -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>
|
||||
{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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}} 默认工作目录',
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user