fix: streamline settings and context status

Settings Center navigation was hard to read and constrained form content,
while Runtime customization duplicated headings and save actions and could
lose drafts. Settings now uses readable navigation and wider content, saves
base and native Runtime settings together, protects drafts, and presents one
compact capabilities and defaults section.

Conversation context meters could retain display-only thresholds from old
Runtime settings. They now persist only measured usage, derive compression
lines from current Runtime and model settings, and normalize legacy snapshots
when loading them.

The static website now uses the project GitHub Pages canonical URL and includes
a validated Pages deployment workflow.

Release note: 优化设置中心和 Agent Runtime 配置流程,避免重复标题、重复保存和未保存定制丢失;压缩线会随当前设置即时更新,官网也可通过 GitHub Pages 自动部署。
This commit is contained in:
mesalogo
2026-08-16 19:31:49 +08:00
parent b56b0f8826
commit 80526c57bf
18 changed files with 953 additions and 306 deletions
+46 -3
View File
@@ -1466,9 +1466,6 @@ describe('AssistantDatabase', () => {
contextMetrics: {
runtimeSelectionKey: `model:${channelDefaultProfileId}`,
contextTokens: 9_000,
effectiveTriggerTokens: 20_000,
contextWindowTokens: 32_000,
compressionEnabled: true,
source: 'estimated' as const,
basis: 'conversation' as const
},
@@ -1574,6 +1571,26 @@ describe('AssistantDatabase', () => {
)
const durable = new DatabaseSync(databasePath)
const contextStateRow = durable
.prepare(
`SELECT context_state_json
FROM conversations
WHERE id = ?`
)
.get(conversationId) as {
context_state_json: string
}
const contextState = JSON.parse(
contextStateRow.context_state_json
) as {
contextMetrics?: unknown
}
expect(contextState.contextMetrics).toEqual({
runtimeSelectionKey: `model:${channelDefaultProfileId}`,
contextTokens: 9_000,
source: 'estimated',
basis: 'conversation'
})
expect(
durable
.prepare(
@@ -1595,6 +1612,32 @@ describe('AssistantDatabase', () => {
request_id: null
}
])
durable
.prepare(
`UPDATE conversations
SET context_state_json = ?
WHERE id = ?`
)
.run(
JSON.stringify({
contextMetrics: {
runtimeSelectionKey: `model:${channelDefaultProfileId}`,
contextTokens: 9_000,
effectiveTriggerTokens: 20_000,
contextWindowTokens: 32_000,
compressionEnabled: true,
source: 'estimated',
basis: 'conversation'
}
}),
conversationId
)
expect(database.getConversation(conversationId).contextMetrics).toEqual({
runtimeSelectionKey: `model:${channelDefaultProfileId}`,
contextTokens: 9_000,
source: 'estimated',
basis: 'conversation'
})
durable.close()
database.close()
})
+213 -10
View File
@@ -2607,7 +2607,7 @@ describe('App', () => {
})),
contextCompression: {
enabled: true,
triggerTokens: 20_000,
triggerTokens: 12_000,
recentRawTokens: 4_000,
modelSource: { kind: 'current' },
summaryPrompt: 'Preserve important facts.'
@@ -2636,7 +2636,7 @@ describe('App', () => {
})
expect(
screen.getByText('本次调用 22.0K · 压缩线 20.0K')
screen.getByText('本次调用 22.0K · 压缩线 12.0K')
).toBeInTheDocument()
expect(
screen.queryByRole('progressbar', {
@@ -2661,14 +2661,99 @@ describe('App', () => {
expect(
screen.getByText(
'压缩后对话估算 ≈9.4K · 压缩线 20.0K'
'压缩后对话估算 ≈9.4K · 压缩线 12.0K'
)
).toBeInTheDocument()
expect(
screen.queryByText('本次调用 22.0K · 压缩线 20.0K')
screen.queryByText('本次调用 22.0K · 压缩线 12.0K')
).not.toBeInTheDocument()
})
it('updates the composer compression line immediately after settings change', async () => {
const settings = await api.settings.getRuntime()
const initialSettings = {
...settings,
provider: 'model' as const,
modelProfiles: settings.modelProfiles.map((profile) => ({
...profile,
contextWindowTokens: undefined
})),
contextCompression: {
enabled: true,
triggerTokens: 20_000,
recentRawTokens: 4_000,
modelSource: { kind: 'current' as const },
summaryPrompt: 'Preserve important facts.'
}
}
vi.mocked(api.settings.getRuntime)
.mockResolvedValueOnce(initialSettings)
.mockResolvedValueOnce(initialSettings)
vi.mocked(api.settings.updateRuntime).mockImplementationOnce(
async (input) => ({
...initialSettings,
contextCompression:
input.contextCompression ??
initialSettings.contextCompression
})
)
render(<App />)
fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), {
target: { value: '检查压缩线设置刷新' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
if (!request) {
throw new Error('Missing request')
}
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'context-metrics',
contextTokens: 9_000,
effectiveTriggerTokens: 20_000,
compressionEnabled: true,
source: 'provider'
})
})
expect(
screen.getByText('本次调用 9.0K · 压缩线 20.0K')
).toBeInTheDocument()
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'done'
})
})
fireEvent.click(
screen.getByRole('button', { name: //u })
)
await screen.findByRole('heading', { name: '设置中心' })
fireEvent.click(
screen.getByRole('tab', { name: '上下文控制' })
)
const trigger = await screen.findByLabelText('压缩触发阈值')
fireEvent.change(trigger, { target: { value: '12' } })
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(api.settings.updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
contextCompression: expect.objectContaining({
triggerTokens: 12_000
})
})
)
)
fireEvent.click(screen.getByRole('button', { name: '对话' }))
expect(
await screen.findByText('本次调用 9.0K · 压缩线 12.0K')
).toBeInTheDocument()
})
it('restores persisted context usage and compression state after restart', async () => {
const settings = await api.settings.getRuntime()
const profile = settings.modelProfiles[0]!
@@ -2689,8 +2774,15 @@ describe('App', () => {
defaultModelProfileId: profile.id,
modelProfiles: settings.modelProfiles.map((candidate) => ({
...candidate,
contextWindowTokens: 32_000
}))
contextWindowTokens: undefined
})),
contextCompression: {
enabled: true,
triggerTokens: 12_000,
recentRawTokens: 4_000,
modelSource: { kind: 'current' },
summaryPrompt: 'Preserve important facts.'
}
})
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
@@ -2703,9 +2795,6 @@ describe('App', () => {
contextMetrics: {
runtimeSelectionKey: `model:${profile.id}`,
contextTokens: 9_000,
effectiveTriggerTokens: 20_000,
contextWindowTokens: 32_000,
compressionEnabled: true,
source: 'estimated',
basis: 'conversation'
},
@@ -2740,7 +2829,7 @@ describe('App', () => {
expect(
await screen.findByText(
'压缩后对话估算 ≈9.0K / 32.0K · 28%'
'压缩后对话估算 ≈9.0K · 压缩线 12.0K'
)
).toBeInTheDocument()
expect(
@@ -4649,6 +4738,17 @@ describe('App', () => {
coveredThroughMessageId: messages[0]!.id,
summary: '用户提出了第一轮问题。'
}
const settings = await api.settings.getRuntime()
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
...settings,
contextCompression: {
enabled: true,
triggerTokens: 20_000,
recentRawTokens: 4_000,
modelSource: { kind: 'current' },
summaryPrompt: 'Preserve important facts.'
}
})
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: conversationId,
@@ -4723,6 +4823,9 @@ describe('App', () => {
expect(
await screen.findByText('已压缩 Continue 对话历史')
).toBeInTheDocument()
expect(
await screen.findByText(//u)
).not.toHaveTextContent('压缩线')
await waitFor(() =>
expect(api.conversations.saveLocal).toHaveBeenCalledWith([
expect.objectContaining({
@@ -5358,8 +5461,39 @@ describe('App', () => {
})
it('migrates legacy startup conversations with replace when SQLite has no local conversation', async () => {
const settings = await api.settings.getRuntime()
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
...settings,
modelProfiles: settings.modelProfiles.map((profile) => ({
...profile,
contextWindowTokens: undefined
})),
contextCompression: {
enabled: true,
triggerTokens: 12_000,
recentRawTokens: 4_000,
modelSource: { kind: 'current' },
summaryPrompt: 'Preserve important facts.'
}
})
const normalizedContextMetrics = {
runtimeSelectionKey: `model:${settings.defaultModelProfileId}`,
contextTokens: 9_000,
source: 'provider' as const,
basis: 'model-call' as const
}
const legacyConversation = {
id: '00000000-0000-4000-8000-000000000461',
runtimeSelection: {
provider: 'model' as const,
profileId: settings.defaultModelProfileId
},
contextMetrics: {
...normalizedContextMetrics,
effectiveTriggerTokens: 20_000,
contextWindowTokens: 32_000,
compressionEnabled: true
},
title: '待迁移旧会话',
updatedAt: 1_775_000_000_000,
messages: [
@@ -5381,10 +5515,14 @@ describe('App', () => {
expect(
await screen.findByText('旧版浏览器存储消息')
).toBeInTheDocument()
expect(
screen.getByText('本次调用 9.0K · 压缩线 12.0K')
).toBeInTheDocument()
await waitFor(() =>
expect(api.conversations.replace).toHaveBeenCalledWith([
expect.objectContaining({
...legacyConversation,
contextMetrics: normalizedContextMetrics,
projectId
})
])
@@ -5443,11 +5581,35 @@ describe('App', () => {
})
it('preserves a legacy Auto conversation without silently persisting a replacement', async () => {
const settings = await api.settings.getRuntime()
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
...settings,
provider: 'auto',
opencodeBaseUrl: '',
opencodeEmbedded: false,
modelProfiles: settings.modelProfiles.map((profile) => ({
...profile,
contextWindowTokens: 32_000
})),
contextCompression: {
enabled: true,
triggerTokens: 20_000,
recentRawTokens: 4_000,
modelSource: { kind: 'current' },
summaryPrompt: 'Preserve important facts.'
}
})
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: '00000000-0000-4000-8000-000000000020',
projectId,
runtimeSelection: { provider: 'auto' },
contextMetrics: {
runtimeSelectionKey: 'auto:default',
contextTokens: 9_000,
source: 'provider',
basis: 'model-call'
},
title: '旧自动对话',
updatedAt: 1,
messages: [
@@ -5466,8 +5628,49 @@ describe('App', () => {
expect(
await screen.findByRole('button', { name: /.*sonnet-5/u })
).toBeInTheDocument()
expect(
screen.queryByText(/ 9\.0K/u)
).not.toBeInTheDocument()
expect(screen.queryByText(/线/u)).not.toBeInTheDocument()
expect(api.conversations.replace).not.toHaveBeenCalled()
expect(api.conversations.saveLocal).not.toHaveBeenCalled()
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '刷新自动 Runtime 用量' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
if (!request) {
throw new Error('Missing request')
}
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'context-metrics',
contextTokens: 9_000,
effectiveTriggerTokens: 20_000,
compressionEnabled: false,
source: 'provider'
})
})
expect(screen.getByText(/ 9\.0K/u)).toBeInTheDocument()
expect(screen.queryByText(/线/u)).not.toBeInTheDocument()
const currentRuntimeSelectionKey =
settings.opencodeModelSource.kind === 'profile'
? `opencode:${settings.opencodeModelSource.profileId}`
: 'opencode:platform'
await waitFor(() =>
expect(api.conversations.saveLocal).toHaveBeenCalledWith([
expect.objectContaining({
header: expect.objectContaining({
contextMetrics: expect.objectContaining({
runtimeSelectionKey: currentRuntimeSelectionKey
})
})
})
])
)
})
it('keeps a removed model selection visible until the user replaces it', async () => {
+65 -56
View File
@@ -76,7 +76,8 @@ import {
import {
buildConversationSummaryHistory,
estimatedContextRequestOverheadTokens,
estimateMessagesTokens
estimateMessagesTokens,
getEffectiveContextTriggerTokens
} from '../../shared/context-window'
import {
agentRuntimeSelectionKey,
@@ -114,6 +115,7 @@ import type {
import {
conversationAttachmentSchema,
conversationContextCompressionMarkerSchema,
conversationContextMetricsSchema,
conversationMessageBlocksSchema,
interactiveWorkModes,
normalizeInteractiveWorkMode,
@@ -963,6 +965,11 @@ function isConversationAttachment(
return conversationAttachmentSchema.safeParse(value).success
}
function parseConversationContextMetrics(value: unknown) {
const parsed = conversationContextMetricsSchema.safeParse(value)
return parsed.success ? parsed.data : undefined
}
function loadConversations(
greeting: string,
interruptedStatus: string
@@ -984,6 +991,12 @@ function loadConversations(
.slice(0, 100)
.map((conversation) => ({
...conversation,
contextMetrics:
conversation.contextMetrics === undefined
? undefined
: parseConversationContextMetrics(
conversation.contextMetrics
),
messages: conversation.messages.slice(-500).map((message) =>
message.state === 'streaming'
? {
@@ -1212,6 +1225,19 @@ function getProjectDefaultRuntimeSelection(
: selection
}
function resolveContextMetricsRuntimeSelection(
selection: AgentRuntimeSelection,
settings: RuntimeSettings
): AgentRuntimeSelection {
if (selection.provider !== 'auto') {
return selection
}
return getRuntimeSelectionForProvider(
settings.provider === 'auto' ? 'opencode' : settings.provider,
settings
)
}
function getRuntimeSelectionLabel(
selection: AgentRuntimeSelection | undefined,
settings: RuntimeSettings | undefined,
@@ -3405,16 +3431,14 @@ function App(): React.JSX.Element {
}
})
} else if (event.type === 'context-metrics') {
const { requestId: _requestId, type: _type, ...metrics } = event
void _requestId
void _type
setConversations((current) =>
current.map((conversation) =>
conversation.id === run.conversationId
? {
...conversation,
contextMetrics: {
...metrics,
contextTokens: event.contextTokens,
source: event.source,
basis: 'model-call',
runtimeSelectionKey: run.runtimeSelectionKey
}
@@ -3475,11 +3499,6 @@ function App(): React.JSX.Element {
contextMetrics: {
runtimeSelectionKey: run.runtimeSelectionKey,
contextTokens: estimatedAfterTokens,
effectiveTriggerTokens:
event.effectiveTriggerTokens,
contextWindowTokens:
event.contextWindowTokens,
compressionEnabled: true,
source: 'estimated',
basis: 'conversation'
},
@@ -5434,7 +5453,12 @@ function App(): React.JSX.Element {
messageId: assistantMessage.id,
projectId: projectIdSnapshot,
runtimeSelectionKey: agentRuntimeSelectionKey(
runtimeSelectionSnapshot
runtimeSettings
? resolveContextMetricsRuntimeSelection(
runtimeSelectionSnapshot,
runtimeSettings
)
: runtimeSelectionSnapshot
)
})
preparingConversations.current.delete(conversationId)
@@ -5592,27 +5616,6 @@ function App(): React.JSX.Element {
content: message.content
}))
])
const selectedProfileId =
'profileId' in activeRuntimeSelection
? activeRuntimeSelection.profileId
: undefined
const configuredSelection = runtimeSettings
? getRuntimeSelectionForProvider(
activeRuntimeSelection.provider,
runtimeSettings
)
: undefined
const configuredProfileId =
configuredSelection &&
'profileId' in configuredSelection
? configuredSelection.profileId
: undefined
const contextWindowTokens =
runtimeSettings?.modelProfiles.find(
(profile) =>
profile.id ===
(selectedProfileId ?? configuredProfileId)
)?.contextWindowTokens
setConversations((current) =>
current.map((conversation) =>
conversation.id === activeConversation.id
@@ -5623,15 +5626,6 @@ function App(): React.JSX.Element {
runtimeSelectionKey:
activeRuntimeSelectionKey,
contextTokens: estimatedAfterTokens,
effectiveTriggerTokens:
contextWindowTokens ??
runtimeSettings?.contextCompression
?.triggerTokens ??
defaultContextCompressionSettings.triggerTokens,
...(contextWindowTokens
? { contextWindowTokens }
: {}),
compressionEnabled: false,
source: 'estimated',
basis: 'conversation'
},
@@ -6175,28 +6169,42 @@ function App(): React.JSX.Element {
) {
return undefined
}
if (
activeRuntimeSelection.provider === 'model' &&
runtimeSettings.modelProfiles.find(
(candidate) =>
candidate.id === activeRuntimeSelection.profileId
)?.protocol === 'openai-images-generations'
) {
const resolvedRuntimeSelection =
resolveContextMetricsRuntimeSelection(
activeRuntimeSelection,
runtimeSettings
)
const activeModelProfile =
'profileId' in resolvedRuntimeSelection &&
resolvedRuntimeSelection.profileId
? runtimeSettings.modelProfiles.find(
(candidate) =>
candidate.id === resolvedRuntimeSelection.profileId
)
: undefined
if (activeModelProfile?.protocol === 'openai-images-generations') {
return undefined
}
const latest = activeConversation.contextMetrics
const applicableLatest =
latest?.runtimeSelectionKey === activeRuntimeSelectionKey
latest?.runtimeSelectionKey ===
agentRuntimeSelectionKey(resolvedRuntimeSelection)
? latest
: undefined
if (!applicableLatest) {
return undefined
}
const compressionSettings =
runtimeSettings.contextCompression ??
defaultContextCompressionSettings
const contextTokens = applicableLatest.contextTokens
const effectiveTriggerTokens =
applicableLatest.effectiveTriggerTokens
const denominatorTokens =
applicableLatest.contextWindowTokens
const contextWindowTokens =
activeModelProfile?.contextWindowTokens
const effectiveTriggerTokens = getEffectiveContextTriggerTokens({
triggerTokens: compressionSettings.triggerTokens,
contextWindowTokens
})
const denominatorTokens = contextWindowTokens
const percentage =
denominatorTokens === undefined
? undefined
@@ -6207,8 +6215,10 @@ function App(): React.JSX.Element {
return {
contextTokens,
effectiveTriggerTokens,
contextWindowTokens: applicableLatest.contextWindowTokens,
compressionEnabled: applicableLatest.compressionEnabled,
contextWindowTokens,
compressionEnabled:
resolvedRuntimeSelection.provider === 'model' &&
compressionSettings.enabled,
source: applicableLatest.source,
basis:
applicableLatest.basis ??
@@ -6222,7 +6232,6 @@ function App(): React.JSX.Element {
}, [
activeConversation,
activeRuntimeSelection,
activeRuntimeSelectionKey,
runtimeSettings
])
+168 -91
View File
@@ -1,12 +1,20 @@
import {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState
} from 'react'
import { Boxes, RefreshCw, Plus, Trash2 } from 'lucide-react'
import {
Boxes,
Plus,
RefreshCw,
RotateCcw,
Trash2
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import {
runtimeCustomizationLimits,
@@ -15,13 +23,16 @@ import {
type RuntimeCustomizationSettings,
type RuntimeNativeSnapshot
} from '../../shared/contracts'
import type { AppNotificationInput } from './notifications'
import { EmptyState, PageTabs } from './WorkspacePrimitives'
type RuntimeCustomizationSectionProps = {
provider: CustomizableRuntimeProvider
profileId?: string
onNotify?: (notification: AppNotificationInput) => void
onDirtyChange?: (dirty: boolean) => void
}
export type RuntimeCustomizationSectionHandle = {
save: () => Promise<boolean>
}
type RuntimeCustomizationError = {
@@ -143,6 +154,25 @@ const NativeInventoryTabs = memo(function NativeInventoryTabs({
)
})
const NativeInventoryStatus = memo(function NativeInventoryStatus({
snapshot
}: {
snapshot: RuntimeNativeSnapshot
}): React.JSX.Element {
return (
<div
className={`runtime-native-inventory__status runtime-native-inventory__status--${snapshot.inventoryStatus}`}
role={
snapshot.inventoryStatus === 'unavailable'
? 'alert'
: 'status'
}
>
<span>{snapshot.detail}</span>
</div>
)
})
const NativeInventory = memo(function NativeInventory({
snapshot
}: {
@@ -323,24 +353,6 @@ const NativeInventory = memo(function NativeInventory({
]
return (
<div className="runtime-native-inventory">
<div
className={`runtime-native-inventory__status runtime-native-inventory__status--${snapshot.inventoryStatus}`}
role={
snapshot.inventoryStatus === 'unavailable'
? 'alert'
: 'status'
}
>
<strong>
{t(
`runtime.customization.inventory.status.${snapshot.inventoryStatus}`
)}
</strong>
<small>{snapshot.detail}</small>
</div>
<p className="settings-section__description">
{t('runtime.customization.inventory.nativeOnly')}
</p>
<NativeInventoryTabs
groups={groups}
provider={snapshot.provider}
@@ -349,15 +361,19 @@ const NativeInventory = memo(function NativeInventory({
)
})
export function RuntimeCustomizationSection({
provider,
profileId,
onNotify
}: RuntimeCustomizationSectionProps): React.JSX.Element {
export const RuntimeCustomizationSection = forwardRef<
RuntimeCustomizationSectionHandle,
RuntimeCustomizationSectionProps
>(function RuntimeCustomizationSection(
{ provider, profileId, onDirtyChange },
ref
): React.JSX.Element {
const { t } = useTranslation('settings')
const loadGeneration = useRef(0)
const [settings, setSettings] =
useState<RuntimeCustomizationSettings>()
const [persistedSettings, setPersistedSettings] =
useState<RuntimeCustomizationSettings>()
const [snapshot, setSnapshot] = useState<RuntimeNativeSnapshot>()
const [selectedPresetId, setSelectedPresetId] = useState('')
const [loading, setLoading] = useState(true)
@@ -365,11 +381,32 @@ export function RuntimeCustomizationSection({
const [saving, setSaving] = useState(false)
const [error, setError] = useState<RuntimeCustomizationError>()
const [mergedRulesOpen, setMergedRulesOpen] = useState(false)
const settingsDirty = useMemo(
() =>
Boolean(
settings &&
persistedSettings &&
JSON.stringify(settings) !== JSON.stringify(persistedSettings)
),
[persistedSettings, settings]
)
const settingsRef = useRef(settings)
const settingsDirtyRef = useRef(settingsDirty)
useEffect(() => {
settingsRef.current = settings
}, [settings])
useEffect(() => {
settingsDirtyRef.current = settingsDirty
onDirtyChange?.(settingsDirty)
}, [onDirtyChange, settingsDirty])
const load = useCallback(async (): Promise<void> => {
const generation = ++loadGeneration.current
setLoading(true)
setRefreshing(false)
setSnapshot(undefined)
setError(undefined)
try {
const [nextSettings, nextSnapshot] = await Promise.all([
@@ -382,15 +419,21 @@ export function RuntimeCustomizationSection({
if (generation !== loadGeneration.current) {
return
}
setSettings(nextSettings)
if (!settingsDirtyRef.current) {
setSettings(nextSettings)
setPersistedSettings(nextSettings)
}
setSnapshot(nextSnapshot)
const draftSettings = settingsDirtyRef.current
? settingsRef.current
: nextSettings
setSelectedPresetId((current) =>
nextSettings.continue.presets.some(
draftSettings?.continue.presets.some(
(preset) => preset.id === current
)
? current
: nextSettings.continue.defaultPresetId ??
nextSettings.continue.presets[0]?.id ??
: draftSettings?.continue.defaultPresetId ??
draftSettings?.continue.presets[0]?.id ??
''
)
} catch (reason) {
@@ -460,9 +503,12 @@ export function RuntimeCustomizationSection({
[selectedPresetId, settings]
)
const save = async (): Promise<void> => {
const save = useCallback(async (): Promise<boolean> => {
if (!settings) {
return
return true
}
if (!settingsDirty) {
return true
}
setSaving(true)
setError(undefined)
@@ -472,11 +518,8 @@ export function RuntimeCustomizationSection({
settings
)
setSettings(saved)
onNotify?.({
tone: 'success',
message: t('runtime.customization.saved'),
dedupeKey: 'runtime-customization-saved'
})
setPersistedSettings(saved)
return true
} catch (reason) {
setError({
message: errorMessage(
@@ -485,9 +528,25 @@ export function RuntimeCustomizationSection({
),
retry: 'save'
})
return false
} finally {
setSaving(false)
}
}, [settings, settingsDirty, t])
useImperativeHandle(ref, () => ({ save }), [save])
const discardChanges = (): void => {
if (!persistedSettings) {
return
}
setSettings(persistedSettings)
setSelectedPresetId(
persistedSettings.continue.defaultPresetId ??
persistedSettings.continue.presets[0]?.id ??
''
)
setError(undefined)
}
const addPreset = (): void => {
@@ -561,7 +620,7 @@ export function RuntimeCustomizationSection({
<button
aria-label={t('runtime.customization.refresh')}
className="icon-button"
disabled={loading || refreshing || saving}
disabled={!snapshot || refreshing || saving}
onClick={() => void refreshSnapshot()}
title={t('runtime.customization.refresh')}
type="button"
@@ -600,54 +659,62 @@ export function RuntimeCustomizationSection({
</p>
) : null}
{provider === 'opencode' && settings && snapshot ? (
<fieldset
aria-busy={saving}
className="runtime-customization-editor"
disabled={saving}
>
<label className="field">
<span>{t('runtime.customization.opencode.defaultAgent')}</span>
<select
onChange={(event) =>
setSettings({
...settings,
opencode: event.target.value
? { defaultAgent: event.target.value }
: {}
})
}
value={settings.opencode.defaultAgent ?? ''}
>
<option value="">
{t('runtime.customization.opencode.runtimeDefault')}
</option>
{snapshot.agents
.filter(
(agent) =>
!agent.hidden &&
(agent.mode === 'primary' ||
agent.mode === 'all')
)
.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name}
</option>
))}
</select>
<small>
{t('runtime.customization.opencode.agentDescription')}
</small>
</label>
</fieldset>
{snapshot ? (
<NativeInventoryStatus snapshot={snapshot} />
) : null}
{provider === 'continue' && settings ? (
{provider === 'opencode' && settings && snapshot ? (
<label
aria-busy={saving}
className="field runtime-customization-editor"
>
<span>{t('runtime.customization.opencode.defaultAgent')}</span>
<select
aria-label={t(
'runtime.customization.opencode.defaultAgent'
)}
disabled={saving}
onChange={(event) =>
setSettings({
...settings,
opencode: event.target.value
? { defaultAgent: event.target.value }
: {}
})
}
value={settings.opencode.defaultAgent ?? ''}
>
<option value="">
{t('runtime.customization.opencode.runtimeDefault')}
</option>
{snapshot.agents
.filter(
(agent) =>
!agent.hidden &&
(agent.mode === 'primary' ||
agent.mode === 'all')
)
.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name}
</option>
))}
</select>
<small>
{t('runtime.customization.opencode.agentDescription')}
</small>
</label>
) : null}
{provider === 'continue' && settings && snapshot ? (
<fieldset
aria-busy={saving}
className="runtime-customization-editor"
disabled={saving}
>
<legend className="sr-only">
{t('runtime.customization.continue.editorTitle')}
</legend>
<div className="runtime-preset-toolbar">
<label className="field">
<span>
@@ -1069,24 +1136,34 @@ export function RuntimeCustomizationSection({
</details>
</div>
) : (
<p className="settings-empty-state">
{t('runtime.customization.continue.emptyPreset')}
</p>
<EmptyState
description={t(
'runtime.customization.continue.emptyPreset'
)}
icon={<Boxes size={22} />}
level="table"
title={t(
'runtime.customization.continue.emptyPresetTitle'
)}
/>
)}
</fieldset>
) : null}
{settings && provider !== 'deepseek-harness' ? (
<div className="settings-actions runtime-customization-section__actions">
{settingsDirty && provider !== 'deepseek-harness' ? (
<div
className="runtime-customization-section__dirty"
role="status"
>
<span>{t('runtime.customization.unsaved')}</span>
<button
className="primary-button"
disabled={refreshing || saving}
onClick={() => void save()}
className="secondary-button"
disabled={saving}
onClick={discardChanges}
type="button"
>
{saving
? t('runtime.customization.saving')
: t('runtime.customization.save')}
<RotateCcw aria-hidden="true" size={14} />
{t('runtime.customization.discard')}
</button>
</div>
) : null}
@@ -1094,4 +1171,4 @@ export function RuntimeCustomizationSection({
{snapshot ? <NativeInventory snapshot={snapshot} /> : null}
</section>
)
}
})
+162 -23
View File
@@ -7,10 +7,11 @@ import {
waitFor,
within
} from '@testing-library/react'
import { useState } from 'react'
import { useRef, useState } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssistantExpert } from '../../shared/assistant-contracts'
import type {
CustomizableRuntimeProvider,
DesktopApi,
RuntimeSettings
} from '../../shared/contracts'
@@ -24,7 +25,10 @@ import type { SpeechModelSnapshot } from '../../shared/speech-model-contracts'
import { builtinMcpServers } from '../../shared/builtin-mcp-servers'
import { builtinModelToolGroups } from '../../shared/builtin-model-tools'
import { SettingsPanel } from './SettingsPanel'
import { RuntimeCustomizationSection } from './RuntimeCustomizationSection'
import {
RuntimeCustomizationSection,
type RuntimeCustomizationSectionHandle
} from './RuntimeCustomizationSection'
import { changeUiLocale } from './i18n'
import { UiLocaleProvider } from './i18n/UiLocaleProvider'
@@ -481,6 +485,29 @@ const getRuntimeNativeSnapshot = vi.fn<
}
}))
function RuntimeCustomizationTestHarness({
provider
}: {
provider: CustomizableRuntimeProvider
}): React.JSX.Element {
const customizationRef =
useRef<RuntimeCustomizationSectionHandle>(null)
return (
<>
<RuntimeCustomizationSection
provider={provider}
ref={customizationRef}
/>
<button
onClick={() => void customizationRef.current?.save()}
type="button"
>
Runtime
</button>
</>
)
}
describe('SettingsPanel runtime files', () => {
beforeEach(async () => {
vi.clearAllMocks()
@@ -1986,16 +2013,30 @@ describe('SettingsPanel runtime files', () => {
async (input) => input
)
render(<RuntimeCustomizationSection provider="opencode" />)
render(<RuntimeCustomizationTestHarness provider="opencode" />)
const agent = await screen.findByLabelText(/ Runtime Agent/u)
const agent = await screen.findByLabelText('默认 Agent')
expect(agent).toHaveValue('planner')
const nativeStatus = screen.getByRole('status')
expect(nativeStatus).toHaveTextContent('OpenCode 原生能力已就绪')
expect(
Boolean(
nativeStatus.compareDocumentPosition(agent) &
Node.DOCUMENT_POSITION_FOLLOWING
)
).toBe(true)
expect(screen.getByText('能力与默认配置')).toBeInTheDocument()
expect(screen.queryByText('Runtime 原生能力')).not.toBeInTheDocument()
expect(screen.queryByText('OpenCode 默认 Agent')).not.toBeInTheDocument()
expect(
screen.queryByText('Runtime 原生能力可用')
).not.toBeInTheDocument()
const inventoryTabs = screen.getByRole('tablist', {
name: 'Runtime 原生能力'
name: '能力清单'
})
expect(within(inventoryTabs).getAllByRole('tab')).toHaveLength(11)
const agentsTab = within(inventoryTabs).getByRole('tab', {
name: / Agents/u
name: /^Agents/u
})
const agentsPanel = screen.getByRole('tabpanel')
expect(agentsTab).toHaveAttribute('aria-selected', 'true')
@@ -2008,7 +2049,7 @@ describe('SettingsPanel runtime files', () => {
expect(screen.getByText('Explorer')).toBeInTheDocument()
fireEvent.keyDown(agentsTab, { key: 'ArrowRight' })
const toolsTab = within(inventoryTabs).getByRole('tab', {
name: / Tools/u
name: /^Tools/u
})
expect(toolsTab).toHaveAttribute('aria-selected', 'true')
expect(toolsTab).toHaveFocus()
@@ -2026,20 +2067,20 @@ describe('SettingsPanel runtime files', () => {
expect(screen.getByText('未发现')).toBeInTheDocument()
expect(
screen.getByText(
'当前 Runtime 未报告此类别中的可用原生能力。'
'当前 Runtime 未报告此类别中的可用能力。'
)
).toBeInTheDocument()
expect(screen.queryByText('Native MCP')).not.toBeInTheDocument()
fireEvent.click(
within(inventoryTabs).getByRole('tab', {
name: / MCP/u
name: /^MCP/u
})
)
expect(screen.getByText('Native MCP')).toBeInTheDocument()
expect(screen.queryByText('Explorer')).not.toBeInTheDocument()
fireEvent.click(
within(inventoryTabs).getByRole('tab', {
name: / Skills/u
name: /^Skills/u
})
)
expect(screen.getByText('Native Skill')).toBeInTheDocument()
@@ -2047,6 +2088,9 @@ describe('SettingsPanel runtime files', () => {
expect(screen.queryByText('GoodBuddy MCP')).not.toBeInTheDocument()
fireEvent.change(agent, { target: { value: 'reviewer' } })
expect(
screen.getByText(/ Runtime /u)
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '保存 Runtime 定制' })
)
@@ -2056,6 +2100,11 @@ describe('SettingsPanel runtime files', () => {
continue: { presets: [] }
})
)
await waitFor(() =>
expect(
screen.queryByText(/ Runtime /u)
).not.toBeInTheDocument()
)
})
it('distinguishes external OpenCode connectivity from readable native inventory', async () => {
@@ -2073,14 +2122,28 @@ describe('SettingsPanel runtime files', () => {
render(<RuntimeCustomizationSection provider="opencode" />)
expect(
await screen.findByText('仅确认 Runtime 连接')
).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent(
expect(await screen.findByRole('status')).toHaveTextContent(
'External OpenCode connection only'
)
expect(
screen.queryByText('Runtime 原生能力可用')
screen.queryByText('仅确认 Runtime 连接')
).not.toBeInTheDocument()
})
it('uses a guided empty state without a second save action', async () => {
render(<RuntimeCustomizationSection provider="continue" />)
expect(
await screen.findByText('还没有 Continue 预设')
).toBeInTheDocument()
expect(
screen.getByText('使用上方“添加预设”创建 Rules 与 Prompt 模板。')
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '添加预设' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: '保存 Runtime 定制' })
).not.toBeInTheDocument()
})
@@ -2150,7 +2213,7 @@ describe('SettingsPanel runtime files', () => {
async (input) => input
)
render(<RuntimeCustomizationSection provider="continue" />)
render(<RuntimeCustomizationTestHarness provider="continue" />)
expect(
await screen.findByLabelText('默认配置预设')
@@ -2159,25 +2222,25 @@ describe('SettingsPanel runtime files', () => {
screen.getByText('查看最终合并的 2 条 Rule')
).toBeInTheDocument()
const inventoryTabs = screen.getByRole('tablist', {
name: 'Runtime 原生能力'
name: '能力清单'
})
fireEvent.click(
within(inventoryTabs).getByRole('tab', {
name: / Tools/u
name: /^Tools/u
})
)
expect(
screen.getByText('当前 Runtime 不支持静态发现原生 Tools')
screen.getByText('当前 Runtime 不支持静态发现 Tools')
).toBeInTheDocument()
fireEvent.click(
within(inventoryTabs).getByRole('tab', {
name: / Skills/u
name: /^Skills/u
})
)
expect(screen.getByText('未发现')).toBeInTheDocument()
fireEvent.click(
within(inventoryTabs).getByRole('tab', {
name: /MCP Resources/u
name: /^Resources/u
})
)
expect(
@@ -2240,7 +2303,7 @@ describe('SettingsPanel runtime files', () => {
.mockRejectedValueOnce(new Error('保存失败'))
.mockImplementationOnce(async (input) => input)
render(<RuntimeCustomizationSection provider="continue" />)
render(<RuntimeCustomizationTestHarness provider="continue" />)
const nameInput = await screen.findByLabelText('预设名称')
fireEvent.change(nameInput, {
@@ -2248,7 +2311,7 @@ describe('SettingsPanel runtime files', () => {
})
fireEvent.click(
screen.getByRole('button', {
name: '刷新 Runtime 原生能力'
name: '刷新能力清单'
})
)
await waitFor(() =>
@@ -2284,6 +2347,82 @@ describe('SettingsPanel runtime files', () => {
expect(getRuntimeCustomizationSettings).toHaveBeenCalledOnce()
})
it('saves Runtime customization with the page action and protects unsaved drafts', async () => {
const presetId = '00000000-0000-4000-8000-000000000705'
const customization = {
opencode: {},
continue: {
presets: [
{
id: presetId,
name: 'Saved preset',
rules: [],
prompts: []
}
]
}
}
getRuntimeCustomizationSettings
.mockResolvedValueOnce(customization)
.mockResolvedValueOnce(customization)
updateRuntimeCustomizationSettings.mockImplementationOnce(
async (input) => input
)
const onClose = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={onClose}
onSaved={vi.fn()}
/>
)
fireEvent.click(await screen.findByRole('button', { name: 'Continue' }))
const presetName = await screen.findByLabelText('预设名称')
fireEvent.change(presetName, {
target: { value: 'Unsaved preset' }
})
expect(
screen.getByText(/ Runtime /u)
).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '外观' }))
expect(presetName).toHaveValue('Unsaved preset')
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }))
expect(onClose).not.toHaveBeenCalled()
expect(
await screen.findByText(
'请先保存或撤销 Runtime 定制更改,再关闭设置中心。'
)
).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntimeCustomizationSettings).toHaveBeenCalledWith(
expect.objectContaining({
continue: expect.objectContaining({
presets: [
expect.objectContaining({ name: 'Unsaved preset' })
]
})
})
)
)
expect(updateRuntime).toHaveBeenCalled()
await waitFor(() =>
expect(
screen.queryByText(/ Runtime /u)
).not.toBeInTheDocument()
)
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }))
expect(onClose).toHaveBeenCalledOnce()
})
it('opens only saved Runtime-owned config files or fixed config directories', async () => {
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
+49 -38
View File
@@ -46,7 +46,10 @@ import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection'
import { DshMarketplaceSection } from './DshMarketplaceSection'
import { RuntimeCustomizationSection } from './RuntimeCustomizationSection'
import {
RuntimeCustomizationSection,
type RuntimeCustomizationSectionHandle
} from './RuntimeCustomizationSection'
import { PageHeader, SegmentedControl } from './WorkspacePrimitives'
import {
SettingsCategoryHeader,
@@ -658,6 +661,23 @@ export function SettingsPanel({
useState(false)
const [agentRuntimeType, setAgentRuntimeType] =
useState<AgentRuntimeType>('opencode')
const [runtimeCustomizationDirty, setRuntimeCustomizationDirty] =
useState(false)
const runtimeCustomizationRef =
useRef<RuntimeCustomizationSectionHandle>(null)
const handleRuntimeCustomizationDirtyChange = useCallback(
(dirty: boolean): void => {
setRuntimeCustomizationDirty(dirty)
if (!dirty) {
setError((current) =>
current === t('runtime.customization.unsavedClose')
? undefined
: current
)
}
},
[t]
)
const settingsBodyRef = useRef<HTMLDivElement>(null)
const hydrateSettings = useCallback(
(
@@ -848,6 +868,11 @@ export function SettingsPanel({
}
const close = (): void => {
if (runtimeCustomizationDirty) {
setActiveTab('runtime')
setError(t('runtime.customization.unsavedClose'))
return
}
setModelProfiles((profiles) =>
profiles.map((profile) => ({
...profile,
@@ -1008,6 +1033,13 @@ export function SettingsPanel({
}
}
onSaved(value)
if (activeTab === 'runtime') {
const customizationSaved =
(await runtimeCustomizationRef.current?.save()) ?? true
if (!customizationSaved) {
return undefined
}
}
if (notifySuccess) {
onNotify({
tone: 'success',
@@ -1889,18 +1921,6 @@ export function SettingsPanel({
</details>
</div>
)}
{agentRuntimeType === 'opencode' && (
<RuntimeCustomizationSection
onNotify={onNotify}
profileId={
opencodeModelSource.kind === 'profile'
? opencodeModelSource.profileId
: undefined
}
provider="opencode"
/>
)}
{agentRuntimeType === 'continue' && (
<div className="settings-section">
<div className="settings-section__title">
@@ -2108,17 +2128,6 @@ export function SettingsPanel({
</details>
</div>
)}
{agentRuntimeType === 'continue' && (
<RuntimeCustomizationSection
onNotify={onNotify}
profileId={
continueModelSource.kind === 'profile'
? continueModelSource.profileId
: undefined
}
provider="continue"
/>
)}
{agentRuntimeType === 'deepseek-harness' && (
<div className="settings-section">
<div className="settings-section__title">
@@ -2216,23 +2225,25 @@ export function SettingsPanel({
</button>
</details>
</div>
)}
{agentRuntimeType === 'deepseek-harness' && (
<RuntimeCustomizationSection
onNotify={onNotify}
profileId={
deepseekHarnessModelSource.kind === 'profile'
? deepseekHarnessModelSource.profileId
: undefined
}
provider="deepseek-harness"
/>
)}
{agentRuntimeType === 'deepseek-harness' && (
<DshMarketplaceSection onNotify={onNotify} />
)}
</>
)}
<div hidden={activeTab !== 'runtime'}>
<RuntimeCustomizationSection
onDirtyChange={handleRuntimeCustomizationDirtyChange}
profileId={
activeRuntimeModelSource.kind === 'profile'
? activeRuntimeModelSource.profileId
: undefined
}
provider={agentRuntimeType}
ref={runtimeCustomizationRef}
/>
</div>
{activeTab === 'runtime' &&
agentRuntimeType === 'deepseek-harness' && (
<DshMarketplaceSection onNotify={onNotify} />
)}
{activeTab === 'model' && (
<>
@@ -174,6 +174,45 @@ describe('WorkspacePrimitives', () => {
)
})
it('keeps full-page settings navigation readable and content fluid', () => {
expect(stylesheet).toMatch(
/\.settings-page \.settings-panel__body\s*\{[^}]*grid-template-columns:\s*190px minmax\(0,\s*1fr\);/u
)
expect(stylesheet).toMatch(
/\.settings-page \.settings-panel__content\s*\{[^}]*width:\s*min\(100%,\s*var\(--content-standard\)\);/u
)
expect(stylesheet).toMatch(
/\.settings-page \.settings-tabs button strong\s*\{[^}]*font-size:\s*var\(--font-body\);/u
)
expect(stylesheet).toMatch(
/\.settings-page \.settings-tabs button small\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
)
})
it('keeps Runtime customization hierarchy and dangerous actions clear', () => {
expect(stylesheet).toMatch(
/\.runtime-customization-section__header strong\s*\{[^}]*font-size:\s*var\(--font-section-title\);/u
)
expect(stylesheet).toMatch(
/\.runtime-customization-editor\s*\{[^}]*padding:\s*0;[^}]*border:\s*0;[^}]*background:\s*transparent;/u
)
expect(stylesheet).toMatch(
/\.runtime-native-inventory__status\s*\{[^}]*display:\s*flex;[^}]*min-height:\s*42px;/u
)
expect(stylesheet).not.toContain(
'.runtime-native-inventory__header'
)
expect(stylesheet).toMatch(
/\.runtime-customization-section__dirty\s*\{[^}]*display:\s*flex;[^}]*justify-content:\s*space-between;/u
)
expect(stylesheet).toMatch(
/\.danger-ghost\s*\{[^}]*color:\s*var\(--danger\);[^}]*font-size:\s*var\(--font-caption\);/u
)
expect(stylesheet).toMatch(
/\.danger-ghost:disabled\s*\{[^}]*color:\s*var\(--text-muted\);[^}]*cursor:\s*not-allowed;/u
)
})
it('keeps knowledge settings cards separated as page sections', () => {
expect(stylesheet).toMatch(
/\.knowledge-settings\s*\{[^}]*display:\s*grid;[^}]*width:\s*min\(920px,\s*100%\);[^}]*gap:\s*var\(--space-6\);/u
+26 -31
View File
@@ -196,48 +196,41 @@ export const settings = {
permissions:
'Choose Ask or Execute in a conversation. Ask can use only read-only capabilities allowed by the current Runtime. Execute can use enabled tools, and records tool calls in Activity.',
customization: {
title: 'Native Runtime customization',
title: 'Capabilities and defaults',
description:
'Manage capabilities supplied by this Runtime. The inventory excludes Skills assigned by GoodBuddy and temporary GoodBuddy MCP servers.',
refresh: 'Refresh native Runtime capabilities',
'Configure this Runtimes defaults and inspect its built-in capabilities. The inventory excludes Skills assigned by GoodBuddy and temporary MCP servers.',
refresh: 'Refresh capability inventory',
retry: 'Retry',
loading: 'Loading native Runtime capabilities…',
save: 'Save Runtime customization',
saving: 'Saving…',
saved: 'Runtime customization saved',
loading: 'Loading capability inventory…',
unsaved:
'There are unsaved Runtime customization changes. Use “Save settings” at the top right to save everything together.',
discard: 'Discard customization changes',
unsavedClose:
'Save or discard the Runtime customization changes before closing Settings.',
enabled: 'Enabled',
disabled: 'Disabled',
errors: {
load: 'Could not load native Runtime capabilities',
load: 'Could not load capability inventory',
save: 'Could not save Runtime customization'
},
inventory: {
tabsAriaLabel: 'Native Runtime capabilities',
nativeOnly:
'Only Runtime-native configuration and plugin capabilities are shown. GoodBuddy assignments are excluded.',
status: {
available: 'Native Runtime capabilities available',
partial: 'Native Runtime capabilities partially available',
unavailable: 'Native Runtime capabilities unavailable',
'connection-only': 'Runtime connection only',
unsupported: 'Native inventory is not supported'
},
agents: 'Native Agents',
tools: 'Native Tools',
skills: 'Native Skills',
mcp: 'Native MCP',
tabsAriaLabel: 'Capability inventory',
agents: 'Agents',
tools: 'Tools',
skills: 'Skills',
mcp: 'MCP',
commands: 'Commands',
rules: 'Native Rules',
prompts: 'Prompt templates',
resources: 'MCP Resources',
lsp: 'LSP status',
formatters: 'Formatter status',
rules: 'Rules',
prompts: 'Prompts',
resources: 'Resources',
lsp: 'LSP',
formatters: 'Formatters',
empty: 'None detected',
emptyDescription:
'The current Runtime did not report any native capabilities in this category.',
'The current Runtime did not report any capabilities in this category.',
unsupported: 'Not supported by this Runtime',
toolsUnsupported:
'This Runtime does not support static discovery of native Tools',
'This Runtime does not support static discovery of Tools',
toolModes: 'Ask: {{ask}} · Execute: {{execute}}',
toolKind: {
read: 'Read',
@@ -286,12 +279,13 @@ export const settings = {
title: 'Context and compaction'
},
opencode: {
defaultAgent: 'Default Runtime Agent',
defaultAgent: 'Default Agent',
runtimeDefault: 'Let OpenCode choose',
agentDescription:
'Applies only to GoodBuddy-managed local OpenCode. A conversation can still select a different Agent.'
},
continue: {
editorTitle: 'Continue configuration presets',
editPreset: 'Edit configuration preset',
noPresets: 'No presets',
addPreset: 'Add preset',
@@ -321,8 +315,9 @@ export const settings = {
promptContent: '{{name}} content',
removePrompt: 'Delete Prompt {{name}}',
mergedRules: 'View {{count}} merged Rules',
emptyPresetTitle: 'No Continue presets yet',
emptyPreset:
'Add a preset to manage Continue Rules and Prompt templates.'
'Use “Add preset” above to create Rules and Prompt templates.'
}
},
advanced: 'Advanced settings',
+26 -31
View File
@@ -175,46 +175,39 @@ export const settings = {
permissions:
'对话时可选择 Ask 或 Execute。Ask 仅可调用当前 Runtime 允许的只读能力;Execute 可调用已启用工具,调用过程会记录到活动。',
customization: {
title: 'Runtime 原生定制',
title: '能力与默认配置',
description:
'管理当前 Runtime 自己提供的能力;清单不包含 GoodBuddy 分配的 Skills 或临时 MCP。',
refresh: '刷新 Runtime 原生能力',
'设置当前 Runtime 的默认项并查看自带能力;清单不包含 GoodBuddy 分配的 Skills 或临时 MCP。',
refresh: '刷新能力清单',
retry: '重试',
loading: '正在读取 Runtime 原生能力…',
save: '保存 Runtime 定制',
saving: '正在保存',
saved: '已保存 Runtime 定制设置',
loading: '正在读取能力清单…',
unsaved:
'有未保存的 Runtime 定制更改,点击页面右上角“保存设置”统一保存',
discard: '撤销定制更改',
unsavedClose:
'请先保存或撤销 Runtime 定制更改,再关闭设置中心。',
enabled: '已启用',
disabled: '已停用',
errors: {
load: '读取 Runtime 原生能力失败',
load: '读取能力清单失败',
save: '保存 Runtime 定制失败'
},
inventory: {
tabsAriaLabel: 'Runtime 原生能力',
nativeOnly:
'这里只显示 Runtime 原生配置与插件能力,不显示 GoodBuddy 分配内容。',
status: {
available: 'Runtime 原生能力可用',
partial: 'Runtime 原生能力部分可用',
unavailable: 'Runtime 原生能力不可用',
'connection-only': '仅确认 Runtime 连接',
unsupported: 'Runtime 不支持原生能力清单'
},
agents: '原生 Agents',
tools: '原生 Tools',
skills: '原生 Skills',
mcp: '原生 MCP',
tabsAriaLabel: '能力清单',
agents: 'Agents',
tools: 'Tools',
skills: 'Skills',
mcp: 'MCP',
commands: 'Commands',
rules: '原生 Rules',
prompts: 'Prompt 模板',
resources: 'MCP Resources',
lsp: 'LSP 状态',
formatters: 'Formatter 状态',
rules: 'Rules',
prompts: 'Prompts',
resources: 'Resources',
lsp: 'LSP',
formatters: 'Formatters',
empty: '未发现',
emptyDescription: '当前 Runtime 未报告此类别中的可用原生能力。',
emptyDescription: '当前 Runtime 未报告此类别中的可用能力。',
unsupported: '当前 Runtime 不支持',
toolsUnsupported: '当前 Runtime 不支持静态发现原生 Tools',
toolsUnsupported: '当前 Runtime 不支持静态发现 Tools',
toolModes: 'Ask{{ask}} · Execute{{execute}}',
toolKind: {
read: '读取',
@@ -263,12 +256,13 @@ export const settings = {
title: '上下文与压缩'
},
opencode: {
defaultAgent: '默认 Runtime Agent',
defaultAgent: '默认 Agent',
runtimeDefault: '由 OpenCode 选择',
agentDescription:
'只影响 GoodBuddy 管理的本机 OpenCode;聊天中仍可为当前对话单独选择。'
},
continue: {
editorTitle: 'Continue 配置预设',
editPreset: '编辑配置预设',
noPresets: '尚无预设',
addPreset: '添加预设',
@@ -295,7 +289,8 @@ export const settings = {
promptContent: '{{name}} 内容',
removePrompt: '删除 Prompt {{name}}',
mergedRules: '查看最终合并的 {{count}} 条 Rule',
emptyPreset: '添加一个预设后即可管理 Rules 与 Prompt 模板。'
emptyPresetTitle: '还没有 Continue 预设',
emptyPreset: '使用上方“添加预设”创建 Rules 与 Prompt 模板。'
}
},
advanced: '高级设置',
+75 -16
View File
@@ -4949,7 +4949,7 @@ button > svg {
padding: 24px 32px 32px;
overflow: hidden;
align-items: stretch;
grid-template-columns: 190px minmax(0, 760px);
grid-template-columns: 190px minmax(0, 1fr);
justify-content: start;
gap: 28px;
}
@@ -4986,6 +4986,7 @@ button > svg {
}
.settings-page .settings-panel__content {
width: min(100%, var(--content-standard));
min-height: 0;
padding-right: var(--space-1);
overflow-y: auto;
@@ -5048,9 +5049,16 @@ button > svg {
text-align: left;
}
.settings-page .settings-tabs button strong {
font-size: var(--font-body);
line-height: 1.35;
}
.settings-page .settings-tabs button small {
display: block;
margin-top: 3px;
font-size: var(--font-caption);
line-height: 1.35;
}
.settings-page .settings-tabs button[aria-selected='true'] {
@@ -5306,14 +5314,25 @@ button > svg {
.runtime-customization-section__header > div {
min-width: 0;
}
.runtime-customization-section__header > div {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
}
.runtime-customization-section__header strong {
color: var(--text-primary);
font-size: var(--font-section-title);
font-weight: 650;
line-height: 1.4;
}
.runtime-customization-section__header small {
color: var(--text-muted);
font-size: var(--font-caption);
line-height: 1.5;
}
.runtime-customization-section__error {
display: flex;
align-items: center;
@@ -5332,18 +5351,22 @@ button > svg {
.runtime-customization-editor {
min-width: 0;
margin: 0;
padding: var(--space-4);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-subtle);
padding: 0;
border: 0;
background: transparent;
}
.runtime-native-inventory__status {
display: grid;
display: flex;
align-items: center;
min-height: 42px;
padding: var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
gap: var(--space-1);
color: var(--text-secondary);
font-size: var(--font-body);
line-height: 1.5;
overflow-wrap: anywhere;
}
.runtime-native-inventory__status--available {
@@ -5363,13 +5386,16 @@ button > svg {
background: var(--warning-subtle);
}
.runtime-native-inventory__status small {
.runtime-customization-section__dirty {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: var(--space-3);
border-top: 1px solid var(--border-subtle);
color: var(--text-secondary);
overflow-wrap: anywhere;
}
.runtime-customization-section__actions {
justify-content: flex-end;
flex-wrap: wrap;
font-size: var(--font-caption);
gap: var(--space-3);
}
.runtime-native-inventory > .page-tabs {
@@ -7419,6 +7445,39 @@ details.settings-section > :not(summary) + :not(summary) {
color: var(--text-secondary);
}
.danger-ghost {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: var(--control-height);
padding: 0 var(--space-3);
border: 1px solid transparent;
border-radius: var(--radius-control);
background: transparent;
color: var(--danger);
cursor: pointer;
font-size: var(--font-caption);
font-weight: 650;
gap: var(--space-2);
}
.danger-ghost:hover {
border-color: var(--danger-border);
background: var(--danger-subtle);
}
.danger-ghost:disabled {
border-color: transparent;
background: transparent;
color: var(--text-muted);
cursor: not-allowed;
opacity: 0.65;
}
.danger-ghost.icon-button {
padding: 0;
}
.conversation-row {
position: relative;
display: flex;
+13 -4
View File
@@ -251,22 +251,31 @@ export const conversationContextMetricsSchema = z
.object({
runtimeSelectionKey: z.string().trim().min(1).max(1_000),
contextTokens: z.number().int().nonnegative().max(50_000_000),
source: z.enum(['provider', 'estimated']),
basis: z.enum(['model-call', 'conversation']).optional(),
// Accepted only to migrate snapshots saved before display settings
// were derived from the current Runtime configuration.
effectiveTriggerTokens: z
.number()
.int()
.nonnegative()
.max(10_000_000),
.max(10_000_000)
.optional(),
contextWindowTokens: z
.number()
.int()
.nonnegative()
.max(10_000_000)
.optional(),
compressionEnabled: z.boolean(),
source: z.enum(['provider', 'estimated']),
basis: z.enum(['model-call', 'conversation']).optional()
compressionEnabled: z.boolean().optional(),
})
.strict()
.transform((metrics) => ({
runtimeSelectionKey: metrics.runtimeSelectionKey,
contextTokens: metrics.contextTokens,
source: metrics.source,
basis: metrics.basis
}))
export type ConversationContextMetrics = z.infer<
typeof conversationContextMetricsSchema