feat: synchronize channel project settings
This commit is contained in:
@@ -15,6 +15,8 @@ import type {
|
|||||||
DesktopApi
|
DesktopApi
|
||||||
} from '../../shared/contracts'
|
} from '../../shared/contracts'
|
||||||
import type { ApplicationSettings } from '../../shared/application-settings-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(() => ({
|
const speechRecognitionMocks = vi.hoisted(() => ({
|
||||||
startPcmRecording: vi.fn()
|
startPcmRecording: vi.fn()
|
||||||
@@ -696,6 +698,7 @@ describe('App', () => {
|
|||||||
delete document.documentElement.dataset.theme
|
delete document.documentElement.dataset.theme
|
||||||
document.documentElement.style.colorScheme = ''
|
document.documentElement.style.colorScheme = ''
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
api.channels = undefined
|
||||||
vi.mocked(api.conversations.list).mockReset().mockResolvedValue([])
|
vi.mocked(api.conversations.list).mockReset().mockResolvedValue([])
|
||||||
vi.mocked(api.conversations.replace)
|
vi.mocked(api.conversations.replace)
|
||||||
.mockReset()
|
.mockReset()
|
||||||
@@ -3298,6 +3301,235 @@ describe('App', () => {
|
|||||||
expect(channelSettingsTab).toHaveAttribute('aria-selected', 'true')
|
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 () => {
|
it('shows client-created remote conversations without obsolete approval copy', async () => {
|
||||||
const channelProject = {
|
const channelProject = {
|
||||||
...project,
|
...project,
|
||||||
|
|||||||
@@ -7493,8 +7493,10 @@ function App(): React.JSX.Element {
|
|||||||
setRuntimeSettings(settings)
|
setRuntimeSettings(settings)
|
||||||
}}
|
}}
|
||||||
onSetHeartbeatPaused={setHeartbeatPaused}
|
onSetHeartbeatPaused={setHeartbeatPaused}
|
||||||
|
onUpdateProject={updateProject}
|
||||||
open
|
open
|
||||||
presentation="page"
|
presentation="page"
|
||||||
|
projects={projects}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</RouteErrorBoundary>
|
</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,
|
within,
|
||||||
waitFor
|
waitFor
|
||||||
} from '@testing-library/react'
|
} from '@testing-library/react'
|
||||||
|
import type { ComponentProps } from 'react'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import type { ChannelSettingsSnapshot } from '../../shared/channel-settings-contracts'
|
import type { ChannelSettingsSnapshot } from '../../shared/channel-settings-contracts'
|
||||||
import {
|
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 () => {
|
afterEach(async () => {
|
||||||
cleanup()
|
cleanup()
|
||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
@@ -188,7 +206,7 @@ describe('ChannelSettingsSection', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const onNotify = vi.fn()
|
const onNotify = vi.fn()
|
||||||
render(<ChannelSettingsSection onNotify={onNotify} />)
|
renderChannelSettings({ onNotify })
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
await screen.findByRole('tab', { name: '企业微信' })
|
await screen.findByRole('tab', { name: '企业微信' })
|
||||||
)
|
)
|
||||||
@@ -200,6 +218,9 @@ describe('ChannelSettingsSection', () => {
|
|||||||
fireEvent.change(screen.getByLabelText('企业微信机器人 ID'), {
|
fireEvent.change(screen.getByLabelText('企业微信机器人 ID'), {
|
||||||
target: { value: 'bot-1' }
|
target: { value: 'bot-1' }
|
||||||
})
|
})
|
||||||
|
fireEvent.change(screen.getByLabelText('企业微信 项目说明'), {
|
||||||
|
target: { value: '企业微信同步项目' }
|
||||||
|
})
|
||||||
fireEvent.change(screen.getByLabelText('企业微信Secret'), {
|
fireEvent.change(screen.getByLabelText('企业微信Secret'), {
|
||||||
target: { value: 'channel-secret' }
|
target: { value: 'channel-secret' }
|
||||||
})
|
})
|
||||||
@@ -235,6 +256,7 @@ describe('ChannelSettingsSection', () => {
|
|||||||
expect(updateProject).toHaveBeenCalledWith(
|
expect(updateProject).toHaveBeenCalledWith(
|
||||||
projects[1]!.id,
|
projects[1]!.id,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
description: '企业微信同步项目',
|
||||||
rootPath: 'C:\\RemoteWorkspace',
|
rootPath: 'C:\\RemoteWorkspace',
|
||||||
defaultWorkMode: 'execute',
|
defaultWorkMode: 'execute',
|
||||||
runtimeSelection: {
|
runtimeSelection: {
|
||||||
@@ -289,7 +311,7 @@ describe('ChannelSettingsSection', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const onNotify = vi.fn()
|
const onNotify = vi.fn()
|
||||||
render(<ChannelSettingsSection onNotify={onNotify} />)
|
renderChannelSettings({ onNotify })
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
await screen.findByRole('tab', { name: '钉钉' })
|
await screen.findByRole('tab', { name: '钉钉' })
|
||||||
)
|
)
|
||||||
@@ -329,7 +351,7 @@ describe('ChannelSettingsSection', () => {
|
|||||||
} as unknown as DesktopApi
|
} as unknown as DesktopApi
|
||||||
})
|
})
|
||||||
|
|
||||||
render(<ChannelSettingsSection />)
|
renderChannelSettings()
|
||||||
const trigger = await screen.findByRole('button', {
|
const trigger = await screen.findByRole('button', {
|
||||||
name: '扫码绑定'
|
name: '扫码绑定'
|
||||||
})
|
})
|
||||||
@@ -380,7 +402,7 @@ describe('ChannelSettingsSection', () => {
|
|||||||
} as unknown as DesktopApi
|
} as unknown as DesktopApi
|
||||||
})
|
})
|
||||||
|
|
||||||
render(<ChannelSettingsSection />)
|
renderChannelSettings()
|
||||||
const disconnect = await screen.findByRole('button', {
|
const disconnect = await screen.findByRole('button', {
|
||||||
name: '断开本机绑定'
|
name: '断开本机绑定'
|
||||||
})
|
})
|
||||||
@@ -420,7 +442,7 @@ describe('ChannelSettingsSection', () => {
|
|||||||
} as unknown as DesktopApi
|
} as unknown as DesktopApi
|
||||||
})
|
})
|
||||||
|
|
||||||
render(<ChannelSettingsSection />)
|
renderChannelSettings()
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
await screen.findByRole('button', { name: '扫码绑定' })
|
await screen.findByRole('button', { name: '扫码绑定' })
|
||||||
)
|
)
|
||||||
@@ -465,7 +487,7 @@ describe('ChannelSettingsSection', () => {
|
|||||||
} as unknown as DesktopApi
|
} as unknown as DesktopApi
|
||||||
})
|
})
|
||||||
|
|
||||||
render(<ChannelSettingsSection />)
|
renderChannelSettings()
|
||||||
const backend = await screen.findByLabelText(
|
const backend = await screen.findByLabelText(
|
||||||
'微信 ClawBot 消息处理后端'
|
'微信 ClawBot 消息处理后端'
|
||||||
)
|
)
|
||||||
@@ -552,7 +574,7 @@ describe('ChannelSettingsSection', () => {
|
|||||||
} as unknown as DesktopApi
|
} as unknown as DesktopApi
|
||||||
})
|
})
|
||||||
|
|
||||||
render(<ChannelSettingsSection />)
|
renderChannelSettings()
|
||||||
fireEvent.change(
|
fireEvent.change(
|
||||||
await screen.findByLabelText('微信 ClawBot 默认工作目录'),
|
await screen.findByLabelText('微信 ClawBot 默认工作目录'),
|
||||||
{ target: { value: '' } }
|
{ target: { value: '' } }
|
||||||
@@ -586,7 +608,7 @@ describe('ChannelSettingsSection', () => {
|
|||||||
} as unknown as DesktopApi
|
} as unknown as DesktopApi
|
||||||
})
|
})
|
||||||
|
|
||||||
render(<ChannelSettingsSection />)
|
renderChannelSettings()
|
||||||
const tablist = await screen.findByRole('tablist', {
|
const tablist = await screen.findByRole('tablist', {
|
||||||
name: '消息通道配置'
|
name: '消息通道配置'
|
||||||
})
|
})
|
||||||
@@ -604,6 +626,10 @@ describe('ChannelSettingsSection', () => {
|
|||||||
expect(weixinTab).toHaveAttribute('aria-selected', 'true')
|
expect(weixinTab).toHaveAttribute('aria-selected', 'true')
|
||||||
expect(wecomTab).toHaveAttribute('tabindex', '-1')
|
expect(wecomTab).toHaveAttribute('tabindex', '-1')
|
||||||
expect(dingtalkTab).toHaveAttribute('tabindex', '-1')
|
expect(dingtalkTab).toHaveAttribute('tabindex', '-1')
|
||||||
|
expect(screen.getByText('项目设置')).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByText('与左上角当前通道项目的设置保持同步。')
|
||||||
|
).toBeInTheDocument()
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole('switch', { name: '启用企业微信通道' })
|
screen.queryByRole('switch', { name: '启用企业微信通道' })
|
||||||
).not.toBeInTheDocument()
|
).not.toBeInTheDocument()
|
||||||
@@ -639,7 +665,7 @@ describe('ChannelSettingsSection', () => {
|
|||||||
} as unknown as DesktopApi
|
} as unknown as DesktopApi
|
||||||
})
|
})
|
||||||
|
|
||||||
render(<ChannelSettingsSection initialChannel="wecom" />)
|
renderChannelSettings({ initialChannel: 'wecom' })
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole('tab', { name: '企业微信' })
|
await screen.findByRole('tab', { name: '企业微信' })
|
||||||
@@ -669,7 +695,7 @@ describe('ChannelSettingsSection', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await i18n.changeLanguage('en-US')
|
await i18n.changeLanguage('en-US')
|
||||||
render(<ChannelSettingsSection />)
|
renderChannelSettings()
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole('tablist', {
|
await screen.findByRole('tablist', {
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
FolderOpen,
|
|
||||||
Save,
|
Save,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
Unplug
|
Unplug
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { TFunction } from 'i18next'
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
@@ -22,23 +20,21 @@ import {
|
|||||||
normalizeInteractiveWorkMode,
|
normalizeInteractiveWorkMode,
|
||||||
projectChannels,
|
projectChannels,
|
||||||
type AssistantProject,
|
type AssistantProject,
|
||||||
type InteractiveWorkMode,
|
type ProjectCreateInput,
|
||||||
type ProjectChannel
|
type ProjectChannel
|
||||||
} from '../../shared/assistant-contracts'
|
} 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 { WeixinBindingSnapshot } from '../../shared/weixin-channel-contracts'
|
||||||
import type { AppNotificationInput } from './notifications'
|
import type { AppNotificationInput } from './notifications'
|
||||||
import { trapTabFocus } from './dialog-focus'
|
import { trapTabFocus } from './dialog-focus'
|
||||||
import { PageTabs, SegmentedControl } from './WorkspacePrimitives'
|
import { PageTabs } from './WorkspacePrimitives'
|
||||||
import {
|
import {
|
||||||
SettingsCategoryHeader,
|
SettingsCategoryHeader,
|
||||||
SettingsWarningList
|
SettingsWarningList
|
||||||
} from './SettingsPrimitives'
|
} from './SettingsPrimitives'
|
||||||
|
import {
|
||||||
|
channelProjectDraft,
|
||||||
|
ChannelProjectSettingsFields
|
||||||
|
} from './ChannelProjectSettingsFields'
|
||||||
|
|
||||||
type ChannelDraft = {
|
type ChannelDraft = {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
@@ -49,13 +45,13 @@ type ChannelDraft = {
|
|||||||
allowGroupMessages: boolean
|
allowGroupMessages: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChannelProjectDraft = {
|
type ChannelProjectDraft = ProjectCreateInput & {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
}
|
||||||
description: string
|
|
||||||
rootPath: string
|
type ChannelProjectOverride = {
|
||||||
defaultWorkMode: InteractiveWorkMode
|
sourceKey: string
|
||||||
runtimeSelection: AgentRuntimeSelection
|
value: ChannelProjectDraft
|
||||||
}
|
}
|
||||||
|
|
||||||
const channelOrder: readonly ProjectChannel[] = projectChannels
|
const channelOrder: readonly ProjectChannel[] = projectChannels
|
||||||
@@ -172,14 +168,16 @@ function projectDraftsFrom(
|
|||||||
project.channel,
|
project.channel,
|
||||||
{
|
{
|
||||||
id: project.id,
|
id: project.id,
|
||||||
name: project.name,
|
...channelProjectDraft(
|
||||||
description: project.description,
|
{
|
||||||
rootPath: project.rootPath,
|
name: project.name,
|
||||||
defaultWorkMode: normalizeInteractiveWorkMode(
|
description: project.description,
|
||||||
project.defaultWorkMode
|
rootPath: project.rootPath,
|
||||||
),
|
defaultWorkMode: normalizeInteractiveWorkMode(
|
||||||
runtimeSelection: repairChannelRuntimeSelection(
|
project.defaultWorkMode
|
||||||
project.runtimeSelection ?? { provider: 'auto' },
|
),
|
||||||
|
runtimeSelection: project.runtimeSelection
|
||||||
|
},
|
||||||
runtimeSettings
|
runtimeSettings
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -187,248 +185,42 @@ function projectDraftsFrom(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function usableChannelModelProfiles(
|
function projectDraftKey(project: ChannelProjectDraft): string {
|
||||||
settings: RuntimeSettings
|
return JSON.stringify({
|
||||||
): RuntimeSettings['modelProfiles'] {
|
description: project.description,
|
||||||
return settings.modelProfiles.filter(
|
rootPath: project.rootPath,
|
||||||
isChannelModelProfileUsable
|
defaultWorkMode: project.defaultWorkMode,
|
||||||
)
|
runtimeSelection: project.runtimeSelection
|
||||||
}
|
|
||||||
|
|
||||||
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 ChannelProjectControls({
|
function ChannelProjectCard({
|
||||||
draft,
|
|
||||||
onChange,
|
onChange,
|
||||||
onSelectRoot,
|
onSelectRoot,
|
||||||
|
project,
|
||||||
runtimeSettings
|
runtimeSettings
|
||||||
}: {
|
}: {
|
||||||
draft: ChannelProjectDraft
|
onChange: (next: ChannelProjectDraft) => void
|
||||||
onChange: (draft: ChannelProjectDraft) => void
|
|
||||||
onSelectRoot: () => void
|
onSelectRoot: () => void
|
||||||
|
project: ChannelProjectDraft
|
||||||
runtimeSettings: RuntimeSettings
|
runtimeSettings: RuntimeSettings
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const { t } = useTranslation('integrations')
|
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 (
|
return (
|
||||||
<section
|
<article className="capability-card channel-settings-card">
|
||||||
aria-label={t('channels.project.sectionAriaLabel', {
|
<div className="capability-card__header">
|
||||||
name: draft.name
|
<div>
|
||||||
})}
|
<strong>{t('channels.project.cardTitle')}</strong>
|
||||||
className="channel-project-settings"
|
<small>{t('channels.project.cardDescription')}</small>
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<small>{t('channels.project.rootHelp')}</small>
|
</div>
|
||||||
</label>
|
<ChannelProjectSettingsFields
|
||||||
<label className="field">
|
onChange={(next) => onChange({ ...next, id: project.id })}
|
||||||
<span>{t('channels.project.backendLabel')}</span>
|
onSelectRoot={onSelectRoot}
|
||||||
<select
|
runtimeSettings={runtimeSettings}
|
||||||
aria-label={t('channels.project.backendAriaLabel', {
|
value={project}
|
||||||
name: draft.name
|
/>
|
||||||
})}
|
</article>
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,6 +256,7 @@ function ChannelEditor({
|
|||||||
const prefix = `channel-${channel}`
|
const prefix = `channel-${channel}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<article className="capability-card channel-settings-card">
|
<article className="capability-card channel-settings-card">
|
||||||
<div className="capability-card__header">
|
<div className="capability-card__header">
|
||||||
<div>
|
<div>
|
||||||
@@ -603,13 +396,6 @@ function ChannelEditor({
|
|||||||
<span>{t('channels.credential.groupMessages')}</span>
|
<span>{t('channels.credential.groupMessages')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<ChannelProjectControls
|
|
||||||
draft={project}
|
|
||||||
onChange={onProjectChange}
|
|
||||||
onSelectRoot={onSelectRoot}
|
|
||||||
runtimeSettings={runtimeSettings}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
disabled={testing}
|
disabled={testing}
|
||||||
@@ -624,6 +410,13 @@ function ChannelEditor({
|
|||||||
})}
|
})}
|
||||||
</button>
|
</button>
|
||||||
</article>
|
</article>
|
||||||
|
<ChannelProjectCard
|
||||||
|
onChange={onProjectChange}
|
||||||
|
onSelectRoot={onSelectRoot}
|
||||||
|
project={project}
|
||||||
|
runtimeSettings={runtimeSettings}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -963,13 +756,13 @@ function WeixinChannelEditor({
|
|||||||
{t('channels.weixin.behaviorHelp')}
|
{t('channels.weixin.behaviorHelp')}
|
||||||
</small>
|
</small>
|
||||||
|
|
||||||
<ChannelProjectControls
|
|
||||||
draft={project}
|
|
||||||
onChange={onProjectChange}
|
|
||||||
onSelectRoot={onSelectRoot}
|
|
||||||
runtimeSettings={runtimeSettings}
|
|
||||||
/>
|
|
||||||
</article>
|
</article>
|
||||||
|
<ChannelProjectCard
|
||||||
|
onChange={onProjectChange}
|
||||||
|
onSelectRoot={onSelectRoot}
|
||||||
|
project={project}
|
||||||
|
runtimeSettings={runtimeSettings}
|
||||||
|
/>
|
||||||
{bindingOpen && (
|
{bindingOpen && (
|
||||||
<WeixinQrDialog
|
<WeixinQrDialog
|
||||||
binding={binding}
|
binding={binding}
|
||||||
@@ -986,10 +779,17 @@ function WeixinChannelEditor({
|
|||||||
|
|
||||||
export function ChannelSettingsSection({
|
export function ChannelSettingsSection({
|
||||||
initialChannel = 'weixin',
|
initialChannel = 'weixin',
|
||||||
onNotify = () => undefined
|
onNotify = () => undefined,
|
||||||
|
onUpdateProject,
|
||||||
|
projectList
|
||||||
}: {
|
}: {
|
||||||
initialChannel?: ProjectChannel
|
initialChannel?: ProjectChannel
|
||||||
onNotify?: (notification: AppNotificationInput) => void
|
onNotify?: (notification: AppNotificationInput) => void
|
||||||
|
onUpdateProject: (
|
||||||
|
projectId: string,
|
||||||
|
input: ProjectCreateInput
|
||||||
|
) => Promise<AssistantProject>
|
||||||
|
projectList: AssistantProject[]
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const { t } = useTranslation('integrations')
|
const { t } = useTranslation('integrations')
|
||||||
const tRef = useRef(t)
|
const tRef = useRef(t)
|
||||||
@@ -1004,8 +804,8 @@ export function ChannelSettingsSection({
|
|||||||
const [snapshot, setSnapshot] = useState<ChannelSettingsSnapshot>()
|
const [snapshot, setSnapshot] = useState<ChannelSettingsSnapshot>()
|
||||||
const [runtimeSettings, setRuntimeSettings] =
|
const [runtimeSettings, setRuntimeSettings] =
|
||||||
useState<RuntimeSettings>()
|
useState<RuntimeSettings>()
|
||||||
const [projects, setProjects] = useState<
|
const [projectOverrides, setProjectOverrides] = useState<
|
||||||
Partial<Record<ProjectChannel, ChannelProjectDraft>>
|
Partial<Record<ProjectChannel, ChannelProjectOverride>>
|
||||||
>({})
|
>({})
|
||||||
const [weixinEnabled, setWeixinEnabled] = useState(false)
|
const [weixinEnabled, setWeixinEnabled] = useState(false)
|
||||||
const [binding, setBinding] = useState<WeixinBindingSnapshot>({
|
const [binding, setBinding] = useState<WeixinBindingSnapshot>({
|
||||||
@@ -1049,18 +849,14 @@ export function ChannelSettingsSection({
|
|||||||
}
|
}
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
api.getSnapshot(),
|
api.getSnapshot(),
|
||||||
window.goodbuddy.projects.list(false),
|
|
||||||
api.getWeixinBinding(),
|
api.getWeixinBinding(),
|
||||||
window.goodbuddy.settings.getRuntime()
|
window.goodbuddy.settings.getRuntime()
|
||||||
])
|
])
|
||||||
})()
|
})()
|
||||||
.then(([next, projectList, bindingSnapshot, nextRuntimeSettings]) => {
|
.then(([next, bindingSnapshot, nextRuntimeSettings]) => {
|
||||||
if (active) {
|
if (active) {
|
||||||
applySnapshot(next)
|
applySnapshot(next)
|
||||||
setRuntimeSettings(nextRuntimeSettings)
|
setRuntimeSettings(nextRuntimeSettings)
|
||||||
setProjects(
|
|
||||||
projectDraftsFrom(projectList, nextRuntimeSettings)
|
|
||||||
)
|
|
||||||
setBinding(bindingSnapshot)
|
setBinding(bindingSnapshot)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1090,6 +886,27 @@ export function ChannelSettingsSection({
|
|||||||
}
|
}
|
||||||
}, [closeBinding])
|
}, [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 save = async (): Promise<void> => {
|
||||||
const api = window.goodbuddy.channels
|
const api = window.goodbuddy.channels
|
||||||
if (!api || !snapshot || !runtimeSettings) {
|
if (!api || !snapshot || !runtimeSettings) {
|
||||||
@@ -1132,9 +949,9 @@ export function ChannelSettingsSection({
|
|||||||
setError(undefined)
|
setError(undefined)
|
||||||
setBindingError(undefined)
|
setBindingError(undefined)
|
||||||
try {
|
try {
|
||||||
const updatedProjects = await Promise.all(
|
await Promise.all(
|
||||||
channelProjects.map((project) =>
|
channelProjects.map((project) =>
|
||||||
window.goodbuddy.projects.update(project!.id, {
|
onUpdateProject(project!.id, {
|
||||||
name: project!.name,
|
name: project!.name,
|
||||||
description: project!.description,
|
description: project!.description,
|
||||||
rootPath: project!.rootPath,
|
rootPath: project!.rootPath,
|
||||||
@@ -1143,7 +960,6 @@ export function ChannelSettingsSection({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
setProjects(projectDraftsFrom(updatedProjects, runtimeSettings))
|
|
||||||
if (Object.keys(input).length > 0) {
|
if (Object.keys(input).length > 0) {
|
||||||
applySnapshot(await api.apply(input))
|
applySnapshot(await api.apply(input))
|
||||||
}
|
}
|
||||||
@@ -1165,7 +981,17 @@ export function ChannelSettingsSection({
|
|||||||
channel: ProjectChannel,
|
channel: ProjectChannel,
|
||||||
next: ChannelProjectDraft
|
next: ChannelProjectDraft
|
||||||
): void => {
|
): 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 (
|
const selectRoot = async (
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ import {
|
|||||||
getDefaultRuntimeSelection,
|
getDefaultRuntimeSelection,
|
||||||
getRuntimeSelectionForProvider
|
getRuntimeSelectionForProvider
|
||||||
} from './runtime-selection'
|
} from './runtime-selection'
|
||||||
|
import {
|
||||||
|
channelProjectDraft,
|
||||||
|
ChannelProjectSettingsFields
|
||||||
|
} from './ChannelProjectSettingsFields'
|
||||||
|
|
||||||
type ProjectSwitcherProps = {
|
type ProjectSwitcherProps = {
|
||||||
projects: AssistantProject[]
|
projects: AssistantProject[]
|
||||||
@@ -264,7 +268,7 @@ export function ProjectSwitcher({
|
|||||||
setError(undefined)
|
setError(undefined)
|
||||||
setConfirmingDelete(false)
|
setConfirmingDelete(false)
|
||||||
setDeleteConfirmation('')
|
setDeleteConfirmation('')
|
||||||
setDraft({
|
const nextDraft: ProjectCreateInput = {
|
||||||
name: activeProject.name,
|
name: activeProject.name,
|
||||||
description: activeProject.description,
|
description: activeProject.description,
|
||||||
rootPath: activeProject.rootPath,
|
rootPath: activeProject.rootPath,
|
||||||
@@ -276,7 +280,12 @@ export function ProjectSwitcher({
|
|||||||
(runtimeSettings
|
(runtimeSettings
|
||||||
? getDefaultRuntimeSelection(runtimeSettings)
|
? getDefaultRuntimeSelection(runtimeSettings)
|
||||||
: undefined)
|
: undefined)
|
||||||
})
|
}
|
||||||
|
setDraft(
|
||||||
|
activeProject.kind === 'channel' && runtimeSettings
|
||||||
|
? channelProjectDraft(nextDraft, runtimeSettings)
|
||||||
|
: nextDraft
|
||||||
|
)
|
||||||
restoreFocusTarget.current = 'settings'
|
restoreFocusTarget.current = 'settings'
|
||||||
setDialogMode('settings')
|
setDialogMode('settings')
|
||||||
}}
|
}}
|
||||||
@@ -322,11 +331,25 @@ export function ProjectSwitcher({
|
|||||||
<X size={14} />
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</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>
|
<span>{t('projectSwitcher.dialog.fields.name')}</span>
|
||||||
<input
|
<input
|
||||||
autoFocus={!confirmingDelete}
|
autoFocus={!confirmingDelete}
|
||||||
disabled={busy || activeProject?.kind === 'channel'}
|
disabled={busy}
|
||||||
maxLength={120}
|
maxLength={120}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setDraft((current) => ({
|
setDraft((current) => ({
|
||||||
@@ -336,11 +359,6 @@ export function ProjectSwitcher({
|
|||||||
}
|
}
|
||||||
value={draft.name}
|
value={draft.name}
|
||||||
/>
|
/>
|
||||||
{activeProject?.kind === 'channel' && (
|
|
||||||
<small>
|
|
||||||
{t('projectSwitcher.dialog.channelManaged')}
|
|
||||||
</small>
|
|
||||||
)}
|
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>
|
<span>
|
||||||
@@ -436,6 +454,8 @@ export function ProjectSwitcher({
|
|||||||
{t('projectSwitcher.dialog.defaultRuntimeHelp')}
|
{t('projectSwitcher.dialog.defaultRuntimeHelp')}
|
||||||
</small>
|
</small>
|
||||||
</label>
|
</label>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<p className="project-create-card__error" role="alert">
|
<p className="project-create-card__error" role="alert">
|
||||||
|
|||||||
@@ -282,7 +282,11 @@ const heartbeatSettingsProps = {
|
|||||||
onCreateHeartbeat: vi.fn(async () => {}),
|
onCreateHeartbeat: vi.fn(async () => {}),
|
||||||
onSetHeartbeatPaused: vi.fn(async () => {}),
|
onSetHeartbeatPaused: vi.fn(async () => {}),
|
||||||
onRemoveHeartbeat: 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 = {
|
const assistantExpert: AssistantExpert = {
|
||||||
id: '00000000-0000-4000-8000-000000000101',
|
id: '00000000-0000-4000-8000-000000000101',
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import type {
|
import type {
|
||||||
AssistantExpert,
|
AssistantExpert,
|
||||||
AssistantHeartbeatConfig,
|
AssistantHeartbeatConfig,
|
||||||
|
AssistantProject,
|
||||||
HeartbeatCreateInput,
|
HeartbeatCreateInput,
|
||||||
|
ProjectCreateInput,
|
||||||
ProjectChannel
|
ProjectChannel
|
||||||
} from '../../shared/assistant-contracts'
|
} from '../../shared/assistant-contracts'
|
||||||
import type {
|
import type {
|
||||||
@@ -78,6 +80,11 @@ type SettingsPanelProps = {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSaved: (settings: RuntimeSettings) => void
|
onSaved: (settings: RuntimeSettings) => void
|
||||||
onNotify?: (notification: AppNotificationInput) => void
|
onNotify?: (notification: AppNotificationInput) => void
|
||||||
|
onUpdateProject: (
|
||||||
|
projectId: string,
|
||||||
|
input: ProjectCreateInput
|
||||||
|
) => Promise<AssistantProject>
|
||||||
|
projects: AssistantProject[]
|
||||||
onExpertsChanged?: (experts: AssistantExpert[]) => void
|
onExpertsChanged?: (experts: AssistantExpert[]) => void
|
||||||
onClearLocalData: () => Promise<void>
|
onClearLocalData: () => Promise<void>
|
||||||
heartbeats: AssistantHeartbeatConfig[]
|
heartbeats: AssistantHeartbeatConfig[]
|
||||||
@@ -431,6 +438,8 @@ export function SettingsPanel({
|
|||||||
onClose,
|
onClose,
|
||||||
onSaved,
|
onSaved,
|
||||||
onNotify = () => {},
|
onNotify = () => {},
|
||||||
|
onUpdateProject,
|
||||||
|
projects,
|
||||||
onClearLocalData,
|
onClearLocalData,
|
||||||
heartbeats,
|
heartbeats,
|
||||||
onCreateHeartbeat,
|
onCreateHeartbeat,
|
||||||
@@ -2824,6 +2833,8 @@ export function SettingsPanel({
|
|||||||
<ChannelSettingsSection
|
<ChannelSettingsSection
|
||||||
initialChannel={initialChannel}
|
initialChannel={initialChannel}
|
||||||
onNotify={onNotify}
|
onNotify={onNotify}
|
||||||
|
onUpdateProject={onUpdateProject}
|
||||||
|
projectList={projects}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'roles' && (
|
{activeTab === 'roles' && (
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export const integrations = {
|
|||||||
project: {
|
project: {
|
||||||
sectionAriaLabel: '{{name}} channel project settings',
|
sectionAriaLabel: '{{name}} channel project settings',
|
||||||
identity: 'Channel project',
|
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',
|
rootLabel: 'Default working directory',
|
||||||
rootAriaLabel: '{{name}} default working directory',
|
rootAriaLabel: '{{name}} default working directory',
|
||||||
selectRootAriaLabel:
|
selectRootAriaLabel:
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ export const integrations = {
|
|||||||
project: {
|
project: {
|
||||||
sectionAriaLabel: '{{name}} 通道项目设置',
|
sectionAriaLabel: '{{name}} 通道项目设置',
|
||||||
identity: '通道项目',
|
identity: '通道项目',
|
||||||
|
cardTitle: '项目设置',
|
||||||
|
cardDescription:
|
||||||
|
'与左上角当前通道项目的设置保持同步。',
|
||||||
|
descriptionLabel: '项目说明',
|
||||||
|
descriptionAriaLabel: '{{name}} 项目说明',
|
||||||
rootLabel: '默认工作目录',
|
rootLabel: '默认工作目录',
|
||||||
rootAriaLabel: '{{name}} 默认工作目录',
|
rootAriaLabel: '{{name}} 默认工作目录',
|
||||||
selectRootAriaLabel: '选择 {{name}} 默认工作目录',
|
selectRootAriaLabel: '选择 {{name}} 默认工作目录',
|
||||||
|
|||||||
@@ -5354,7 +5354,9 @@ details.settings-section > :not(summary) + :not(summary) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.channel-settings__panel {
|
.channel-settings__panel {
|
||||||
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-settings__panel > .channel-settings-card {
|
.channel-settings__panel > .channel-settings-card {
|
||||||
@@ -5368,6 +5370,11 @@ details.settings-section > :not(summary) + :not(summary) {
|
|||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channel-project-settings--dialog {
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.channel-project-settings__identity {
|
.channel-project-settings__identity {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user