fix: align channel runtime and settings UI

This commit is contained in:
lofyer
2026-08-09 21:54:25 +08:00
parent 66b098ae36
commit 1cc969317d
17 changed files with 288 additions and 1528 deletions
+37 -1
View File
@@ -2,7 +2,8 @@ import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
import { describe, expect, it } from 'vitest'
import {
applyRuntimeSelection,
getConfiguredRuntimeTarget
getConfiguredRuntimeTarget,
resolveConfiguredAgentRuntimeSelection
} from './runtime-selection'
const defaultProfileId = '00000000-0000-4000-8000-000000000001'
@@ -148,6 +149,41 @@ describe('runtime selection', () => {
).toThrow('自动启动')
})
it('resolves Agent Runtime backends from the global Runtime configuration', () => {
const base = settings()
const configured = settings({
opencodeModelProfile: base.modelProfiles[1],
continueModelProfile: base.modelProfiles[2]
})
expect(
resolveConfiguredAgentRuntimeSelection(configured, {
provider: 'opencode',
profileId: defaultProfileId
})
).toEqual({
provider: 'opencode',
profileId: secondProfileId
})
expect(
resolveConfiguredAgentRuntimeSelection(configured, {
provider: 'continue'
})
).toEqual({
provider: 'continue',
profileId: responsesProfileId
})
expect(
resolveConfiguredAgentRuntimeSelection(configured, {
provider: 'model',
profileId: defaultProfileId
})
).toEqual({
provider: 'model',
profileId: defaultProfileId
})
})
it('routes legacy automatic settings through local OpenCode when the Server is blank', () => {
expect(getConfiguredRuntimeTarget(settings())).toBe('opencode')
expect(
+20
View File
@@ -35,6 +35,26 @@ export function getConfiguredRuntimeTarget(
return 'model'
}
export function resolveConfiguredAgentRuntimeSelection(
settings: ResolvedRuntimeSettings,
selection: AgentRuntimeSelection
): AgentRuntimeSelection {
if (
selection.provider !== 'opencode' &&
selection.provider !== 'continue'
) {
return selection
}
const profile =
selection.provider === 'opencode'
? settings.opencodeModelProfile
: settings.continueModelProfile
return {
provider: selection.provider,
...(profile ? { profileId: profile.id } : {})
}
}
export function applyRuntimeSelection(
settings: ResolvedRuntimeSettings,
selection: AgentRuntimeSelection
@@ -1185,8 +1185,8 @@ describe('AssistantDatabase', () => {
rootPath: channelProject.rootPath,
defaultWorkMode: channelProject.defaultWorkMode,
runtimeSelection: {
provider: 'model',
profileId: removedProfileId
provider: 'opencode',
profileId: runtimeProfileId
}
})
const imageChannelProject = database.ensureChannelProjects(
@@ -1257,8 +1257,7 @@ describe('AssistantDatabase', () => {
{ provider: 'model', profileId: runtimeProfileId }
])
expect(database.getProject(channelProject.id).runtimeSelection).toEqual({
provider: 'model',
profileId: defaultProfileId
provider: 'opencode'
})
expect(
database.getProject(imageChannelProject.id).runtimeSelection
+23 -5
View File
@@ -973,15 +973,18 @@ describe('registerIpcHandlers agent terminal state', () => {
respond: vi.fn(),
clear: vi.fn()
}
const getResolvedSettings = vi.fn(
async (): Promise<Record<string, unknown>> => ({
toolApproval,
subagentSmartRoutingEnabled: smartRoutingEnabled
})
)
const dispose = registerIpcHandlers(
window as never,
runtime as never,
'CommandOrControl+Shift+Space',
{
getResolvedSettings: vi.fn(async () => ({
toolApproval,
subagentSmartRoutingEnabled: smartRoutingEnabled
}))
getResolvedSettings
} as never,
{} as never,
contextManager as never,
@@ -1009,6 +1012,7 @@ describe('registerIpcHandlers agent terminal state', () => {
assistantDatabase,
contextManager,
dispose,
getResolvedSettings,
clearHandler: electronMocks.handlers.get(
ipcChannels.appClearLocalData
),
@@ -2240,6 +2244,8 @@ describe('registerIpcHandlers agent terminal state', () => {
it('routes remote Execute to a configured Agent Runtime without a GoodBuddy approval callback', async () => {
let receivedAuthorize: unknown = 'not-called'
const configuredProfileId =
'00000000-0000-4000-8000-000000000019'
const selectedRuntime = {
runtimeId: 'continue',
capability: 'chat',
@@ -2282,6 +2288,11 @@ describe('registerIpcHandlers agent terminal state', () => {
false,
selectedRuntimes
)
harness.getResolvedSettings.mockResolvedValue({
toolApproval: 'always',
subagentSmartRoutingEnabled: false,
continueModelProfile: { id: configuredProfileId }
})
vi.mocked(
harness.assistantDatabase.listProjects
).mockReturnValue([
@@ -2323,9 +2334,16 @@ describe('registerIpcHandlers agent terminal state', () => {
output: 'Continue 已执行'
})
expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith(
{ provider: 'continue' },
{ provider: 'continue', profileId: configuredProfileId },
'C:\\ProjectWorkspace'
)
expect(
harness.assistantDatabase.getOrCreateRemoteConversation
).toHaveBeenCalledWith(
expect.objectContaining({
runtimeSelection: { provider: 'continue' }
})
)
expect(receivedAuthorize).toBeUndefined()
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
await harness.dispose()
+16 -3
View File
@@ -121,6 +121,7 @@ import {
createDefaultModelRuntime,
createModelProfileRuntime
} from './agent/create-runtime'
import { resolveConfiguredAgentRuntimeSelection } from './agent/runtime-selection'
import { safeToolErrorDetail } from './agent/approval-summary'
import { ReasoningTagStreamParser } from './agent/reasoning-stream'
import type { BundledRuntimePaths } from './agent/bundled-runtimes'
@@ -576,6 +577,7 @@ export function registerIpcHandlers(
const resolveRequestRuntime = async (
request: Pick<AgentRequest, 'projectId' | 'runtimeSelection'> & {
workspaceOverride?: string
followConfiguredAgentRuntime?: boolean
}
): Promise<AgentRuntime> => {
const projectWorkspace =
@@ -586,8 +588,14 @@ export function registerIpcHandlers(
if (!selectedRuntimes || (!request.runtimeSelection && !projectWorkspace)) {
return runtime
}
const selection =
let selection =
request.runtimeSelection ?? ({ provider: 'auto' } as const)
if (request.followConfiguredAgentRuntime) {
selection = resolveConfiguredAgentRuntimeSelection(
await settingsStore.getResolvedSettings(),
selection
)
}
return projectWorkspace
? selectedRuntimes.getRuntime(selection, projectWorkspace)
: selectedRuntimes.getRuntime(selection)
@@ -830,6 +838,7 @@ export function registerIpcHandlers(
rootPath: string
conversationId: string
runtimeSelection: AgentRuntimeSelection
followConfiguredAgentRuntime?: boolean
runtime?: AgentRuntime
taskId?: string
contextIds?: string[]
@@ -888,7 +897,9 @@ export function registerIpcHandlers(
(await resolveRequestRuntime({
projectId: schedule.projectId,
runtimeSelection: remoteContext?.runtimeSelection,
workspaceOverride: remoteContext?.rootPath
workspaceOverride: remoteContext?.rootPath,
followConfiguredAgentRuntime:
remoteContext?.followConfiguredAgentRuntime
}))
const agentRuntimeSelected = isAgentRuntime(requestRuntime)
const channelToolPolicy =
@@ -1455,7 +1466,8 @@ export function registerIpcHandlers(
executionRuntime = await resolveRequestRuntime({
projectId: project.id,
runtimeSelection,
workspaceOverride: project.rootPath
workspaceOverride: project.rootPath,
followConfiguredAgentRuntime: true
})
executionStatus = await executionRuntime.getStatus()
} catch (error) {
@@ -1546,6 +1558,7 @@ export function registerIpcHandlers(
rootPath: project.rootPath,
conversationId: remoteConversation.id,
runtimeSelection,
followConfiguredAgentRuntime: true,
runtime: executionRuntime,
taskId: remoteTaskId,
contextIds,
+45
View File
@@ -863,6 +863,11 @@ describe('App', () => {
provider: 'model',
profileId: modelProfileId
})
expect(
screen
.getByText('正在连接 Agent Runtime')
.querySelector('.message__status-dot')
).toHaveClass('message__status-dot--active')
const userMessage = screen
.getAllByText('帮我分析项目')
.map((element) => element.closest('article'))
@@ -2179,6 +2184,15 @@ describe('App', () => {
})
expect(await screen.findByText('已取消')).toBeInTheDocument()
const cancelledStatus = screen
.getAllByText('请求已取消')
.find((element) => element.classList.contains('message__status'))
expect(cancelledStatus).toBeDefined()
const cancelledDot = cancelledStatus?.querySelector(
'.message__status-dot'
)
expect(cancelledDot).toHaveClass('message__status-dot')
expect(cancelledDot).not.toHaveClass('message__status-dot--active')
fireEvent.click(screen.getByText('任务与活动'))
expect((await screen.findAllByText('已取消')).length).toBeGreaterThan(0)
fireEvent.click(screen.getByRole('button', { name: '进行中' }))
@@ -2187,6 +2201,37 @@ describe('App', () => {
).toBeInTheDocument()
})
it('keeps persisted completed message statuses static', async () => {
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: '00000000-0000-4000-8000-000000000210',
projectId,
title: '已完成会话',
updatedAt: 1_775_000_000_000,
messages: [
{
id: '00000000-0000-4000-8000-000000000211',
role: 'assistant',
content: '任务结果',
createdAt: 1_775_000_000_000,
state: 'complete',
status: '任务已完成'
}
]
}
])
render(<App />)
const completedStatus = await screen.findByText('任务已完成')
expect(
completedStatus.querySelector('.message__status-dot')
).toHaveClass('message__status-dot')
expect(
completedStatus.querySelector('.message__status-dot')
).not.toHaveClass('message__status-dot--active')
})
it('switches runtime profiles from the composer dropdown', async () => {
render(<App />)
+8 -1
View File
@@ -4925,7 +4925,14 @@ function App(): React.JSX.Element {
: 'message__status'
}
>
<span className="thinking-dot" />
<span
aria-hidden="true"
className={
message.state === 'streaming'
? 'message__status-dot message__status-dot--active'
: 'message__status-dot'
}
/>
{message.status}
</div>
)}
@@ -339,6 +339,50 @@ describe('ChannelSettingsSection', () => {
expect(trigger).toHaveFocus()
})
it('renders disconnecting a configured Weixin binding as a danger action', async () => {
const configuredSnapshot: ChannelSettingsSnapshot = {
...snapshot,
weixin: {
enabled: true,
bindingConfigured: true,
accountDisplay: '微信用户',
source: 'encrypted',
status: { state: 'running' }
}
}
const api = bindingApi()
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
channels: {
...api,
getSnapshot: vi.fn(async () => configuredSnapshot),
apply: vi.fn(),
testConnection: vi.fn()
},
projects: {
list: vi.fn(async () => projects),
update: vi.fn()
},
settings: settingsApi()
} as unknown as DesktopApi
})
render(<ChannelSettingsSection />)
const disconnect = await screen.findByRole('button', {
name: '断开本机绑定'
})
expect(disconnect).toHaveClass(
'danger-button',
'danger-button--quiet'
)
fireEvent.click(disconnect)
await waitFor(() =>
expect(api.disconnectWeixin).toHaveBeenCalledOnce()
)
})
it('shows Weixin verification failures inside the QR dialog', async () => {
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
@@ -451,6 +495,11 @@ describe('ChannelSettingsSection', () => {
value: agentRuntimeSelectionKey({ provider: 'opencode' })
}
})
expect(
screen.getByText(
'通过 OpenCode Agent Runtime 运行,并跟随“Agent Runtime”设置中的全局 OpenCode 配置。'
)
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '保存通道设置' })
)
+2 -10
View File
@@ -239,15 +239,7 @@ function runtimeSelectionDescription(
}
const runtimeLabel =
selection.provider === 'opencode' ? 'OpenCode' : 'Continue'
const profile =
'profileId' in selection
? settings.modelProfiles.find(
(candidate) => candidate.id === selection.profileId
)
: undefined
return profile
? `通过 ${runtimeLabel} Agent Runtime 运行,并使用 ${profile.name}`
: `通过 ${runtimeLabel} Agent Runtime 及其当前模型配置运行。`
return `通过 ${runtimeLabel} Agent Runtime 运行,并跟随“Agent Runtime”设置中的全局 ${runtimeLabel} 配置。`
}
function ChannelProjectControls({
@@ -887,7 +879,7 @@ function WeixinChannelEditor({
</button>
{settings.bindingConfigured && (
<button
className="danger-ghost"
className="danger-button danger-button--quiet"
disabled={busy}
onClick={onDisconnect}
type="button"
+10 -2
View File
@@ -2816,14 +2816,18 @@ textarea:focus-visible {
color: #ff4d4f;
}
.thinking-dot {
.message__status-dot {
flex: 0 0 auto;
width: 6px;
height: 6px;
margin-top: 4px;
border-radius: 50%;
background: currentColor;
}
.message__status-dot--active {
animation: pulse 1.1s ease-in-out infinite;
background: #1677ff;
background: var(--accent-solid);
}
.tool-execution-list {
@@ -4191,6 +4195,10 @@ details.settings-section > :not(summary) {
margin-left: var(--space-4);
}
details.settings-section > :not(summary) + :not(summary) {
margin-top: var(--space-3);
}
.model-connection-manager {
display: grid;
min-width: 0;
@@ -80,6 +80,12 @@ export function repairChannelRuntimeSelection(
if (selection.provider === 'auto') {
return defaultDirectSelection
}
if (
selection.provider === 'opencode' ||
selection.provider === 'continue'
) {
return { provider: selection.provider }
}
const repaired = repairAgentRuntimeSelection(selection, settings)
if (repaired.provider !== 'model') {
return repaired