feat: add frosted glass appearance and improve local MCP recovery

Add an opt-in persistent frosted glass setting for the top bar and runtime checklist, with chat scrolling behind the header while preserving scroll anchors and window controls.

Retry transient local MCP initialization failures once after cleanup, include redacted connection diagnostics in feedback, and index lifecycle events to reduce execution statistics scans.

Release note: Add optional frosted glass appearance with chat visible behind the title bar; improve local tool connection recovery and diagnostic feedback.
This commit is contained in:
mesalogo
2026-09-30 23:37:26 +08:00
parent e4f7b8d993
commit f7f501a8e3
39 changed files with 1666 additions and 251 deletions
+10
View File
@@ -73,6 +73,16 @@ function settings(
}
describe('createAgentRuntime model compatibility', () => {
it('passes the desktop failure observer to local OpenCode', async () => {
const observeFailure = vi.fn()
const runtime = createAgentRuntime(process.cwd(), settings({ provider: 'opencode' }), { observeFailure })
try {
expect(runtime).toMatchObject({ options: { observeFailure } })
} finally {
await runtime.dispose()
}
})
it('does not treat the default model profile as the platform DeepSeek source', () => {
const defaultProfile = {
id: '00000000-0000-4000-8000-000000000001',
+3
View File
@@ -44,6 +44,7 @@ import {
type ExecutionSpaceDescriptor
} from '../execution-space'
import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider'
import type { DesktopDiagnosticFailureObserver } from '../desktop-diagnostics'
const noModelTaskTools: ModelToolProviderLike = {
listTools: async () => [],
@@ -58,6 +59,7 @@ const noModelTaskTools: ModelToolProviderLike = {
}
export type AgentCapabilityContext = {
observeFailure?: DesktopDiagnosticFailureObserver
localRuntimeRegistry?: LocalRuntimeRegistry
skillInstructions?: string
skillPackages?: RuntimeSkillPackage[]
@@ -306,6 +308,7 @@ export function createAgentRuntime(
)
}
const options: OpenCodeRuntimeOptions = {
observeFailure: capabilities.observeFailure,
baseUrl,
embedded,
binaryPath:
@@ -24,6 +24,8 @@ import {
} from './knowledge-mcp-gateway'
import { browserTabIdSchema } from '../../shared/contracts'
import { BrowserService } from '../browser/browser-service'
import { createHash } from 'node:crypto'
import { DesktopDiagnostics, type DesktopDiagnosticFailureObserver } from '../desktop-diagnostics'
const firstLibraryId = '11111111-1111-4111-8111-111111111111'
const secondLibraryId = '22222222-2222-4222-8222-222222222222'
@@ -178,6 +180,90 @@ function testTool(name: string): Tool {
const gateways: KnowledgeMcpGateway[] = []
it('records safe HTTP rejection phases and isolates throwing observers', async () => {
const observeFailure = vi.fn<DesktopDiagnosticFailureObserver>(() => { throw new Error('observer failed') })
const gateway = new KnowledgeMcpGateway(createService().service, { observeFailure })
gateways.push(gateway)
await gateway.start()
const token = gateway.grant('private-request', [firstLibraryId], new AbortController().signal)!
const correlationId = `sha256:${createHash('sha256').update(token).digest('hex')}`
const cases = [
{ headers: {}, body: '{}', status: 401, phase: 'authentication' },
{ headers: { authorization: `Bearer ${token}` }, body: 'private-invalid-json', status: 400, phase: 'request' },
{ headers: { authorization: `Bearer ${token}`, 'mcp-session-id': 'private-session' }, body: '{}', status: 404, phase: 'session' },
{ headers: { authorization: `Bearer ${token}` }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'test', version: '1' } } }), status: 406, phase: 'initialize' }
]
for (const entry of cases) {
const response = await fetch(gateway.getEndpoint()!, { method: 'POST', headers: { 'content-type': 'application/json', ...entry.headers }, body: entry.body })
await response.text()
expect(response.status).toBe(entry.status)
expect(observeFailure).toHaveBeenLastCalledWith(expect.objectContaining({
code: 'runtime.mcp.failed', mcp: expect.objectContaining({ phase: entry.phase, category: 'http-rejection', status: entry.status, elapsedMs: expect.any(Number) })
}))
}
expect(observeFailure.mock.calls.at(-1)?.[0]).toMatchObject({ mcp: { correlationId } })
expect(JSON.stringify(observeFailure.mock.calls)).not.toContain(token)
expect(JSON.stringify(observeFailure.mock.calls)).not.toContain('private-')
})
it('cleans up failed HTTP initialization and allows a subsequent connection', async () => {
const observeFailure = vi.fn()
const gateway = new KnowledgeMcpGateway(createService().service, { observeFailure })
gateways.push(gateway)
await gateway.start()
const token = gateway.grant('request', [firstLibraryId], new AbortController().signal)!
const connect = vi.spyOn(McpProtocolServer.prototype, 'connect').mockRejectedValue(new Error('private-connect-error'))
const close = vi.spyOn(McpProtocolServer.prototype, 'close')
for (let index = 0; index < 9; index += 1) {
const response = await fetch(gateway.getEndpoint()!, {
method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify({ jsonrpc: '2.0', id: index, method: 'initialize', params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'test', version: '1' } } })
})
await response.text()
expect(response.status).toBe(500)
}
expect(close).toHaveBeenCalledTimes(9)
expect(observeFailure).toHaveBeenCalledTimes(9)
expect(observeFailure).toHaveBeenLastCalledWith(expect.objectContaining({ mcp: expect.objectContaining({ phase: 'initialize', category: 'handler-failure', status: 500 }) }))
connect.mockRestore()
const client = new Client({ name: 'test', version: '1' })
try {
await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), { requestInit: { headers: { authorization: `Bearer ${token}` } } }))
await client.listTools()
await client.callTool({ name: 'knowledge_list', arguments: {} })
expect(observeFailure).toHaveBeenCalledTimes(9)
} finally { await client.close() }
})
it('persists asynchronous HTTP tool discovery failures without private error details', async () => {
const directory = await mkdtemp(join(tmpdir(), 'gateway-diagnostics-'))
temporaryDirectories.push(directory)
const diagnostics = new DesktopDiagnostics(directory)
const pending: Promise<void>[] = []
let clock = 100
vi.spyOn(performance, 'now').mockImplementation(() => clock)
const gateway = new KnowledgeMcpGateway(createService().service, {
observeFailure: failure => { pending.push(diagnostics.recordFailure(failure)) },
storyGraphService: { available: async () => { clock = 145; throw new Error('private-url-token-body') }, read: () => ({}) }
})
gateways.push(gateway)
await gateway.start()
const token = gateway.grant('private-request', [firstLibraryId], new AbortController().signal, 'none', undefined, undefined, undefined, undefined, undefined, { runtimeTarget: 'opencode' })!
const client = new Client({ name: 'test', version: '1' })
try {
await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), { requestInit: { headers: { authorization: `Bearer ${token}` } } }))
await expect(client.listTools()).rejects.toThrow()
await Promise.all(pending)
const exported = (await diagnostics.exportRecent()).toString('utf8')
expect(JSON.parse(exported)).toMatchObject({ code: 'runtime.mcp.failed', mcp: { phase: 'tool-discovery', category: 'handler-failure', elapsedMs: 45 } })
expect(exported).not.toContain('private-')
expect(exported).not.toContain(token)
} finally {
await client.close()
await diagnostics.dispose()
}
})
it('honors a requested knowledge result count above eight', async () => {
const { service, retrieveMany } = createService()
const [sample] = await retrieveMany([firstLibraryId])
+126 -71
View File
@@ -1,4 +1,5 @@
import { randomBytes } from 'node:crypto'
import { createHash, randomBytes } from 'node:crypto'
import type { DesktopDiagnosticFailureObserver, DesktopMcpDiagnosticMetadata } from '../desktop-diagnostics'
import { isStoryGraphTool, storyGraphToolNames, type StoryGraphToolName } from '../../shared/story-graph-tools'
import type { RuntimeTarget } from '../../shared/capability-contracts'
@@ -256,6 +257,7 @@ type DownstreamMcpSession = {
}
export type KnowledgeMcpGatewayOptions = {
observeFailure?: DesktopDiagnosticFailureObserver
storyGraphService?: StoryGraphService
maximumBodyBytes?: number
magicNotesDatabase?: MagicNotesDatabase
@@ -401,6 +403,7 @@ export class KnowledgeMcpGateway {
): BrowserTabUsageLease
}
private readonly launchEnvironmentProvider?: LaunchEnvironmentProvider
private readonly observeFailure?: DesktopDiagnosticFailureObserver
private server?: Server
private endpoint?: string
@@ -416,6 +419,18 @@ export class KnowledgeMcpGateway {
this.obsidianService = options.obsidianService
this.browserService = options.browserService
this.launchEnvironmentProvider = options.launchEnvironmentProvider
this.observeFailure = options.observeFailure
}
private observeMcpFailure(mcp: DesktopMcpDiagnosticMetadata): void {
try {
this.observeFailure?.({
component: 'runtime', stage: 'connect', code: 'runtime.mcp.failed',
error: new Error('Local MCP request failed'), mcp
})
} catch {
// Diagnostics must not change the HTTP response or session lifecycle.
}
}
async start(): Promise<void> {
@@ -423,7 +438,23 @@ export class KnowledgeMcpGateway {
return
}
const server = createServer((request, response) => {
void this.handleRequest(request, response).catch(() => {
const context = { phase: 'request' as DesktopMcpDiagnosticMetadata['phase'], startedAt: performance.now(), correlationId: undefined as string | undefined }
let failed = false
response.once('finish', () => {
if (!failed && response.statusCode >= 400) {
this.observeMcpFailure({
phase: context.phase, category: 'http-rejection', status: response.statusCode,
elapsedMs: Math.round(performance.now() - context.startedAt), correlationId: context.correlationId
})
}
})
void this.handleRequest(request, response, context).catch(() => {
failed = true
this.observeMcpFailure({
phase: context.phase, category: 'handler-failure',
...(response.headersSent ? {} : { status: 500 }),
elapsedMs: Math.round(performance.now() - context.startedAt), correlationId: context.correlationId
})
sendJson(response, 500, {
jsonrpc: '2.0',
error: { code: -32603, message: 'Internal server error' },
@@ -1683,76 +1714,86 @@ export class KnowledgeMcpGateway {
mcp.setRequestHandler(
ListToolsRequestSchema,
async (_request, extra) => {
const customBindings = await this.getCustomMcpBindings(
token,
extra.signal
)
const storyGraphAvailable = await this.isStoryGraphAvailable(token)
const scopedTools = [...availableTools].filter(name => !name.startsWith('story_graph_') || storyGraphAvailable).flatMap(
(name): Tool[] => {
const definition = scopedDataToolByName.get(
name as ScopedDataToolName
)
if (!definition) {
return []
}
const inputSchema = z.toJSONSchema(
definition.inputSchema,
{ target: 'draft-7' }
) as Tool['inputSchema'] & { $schema?: string }
Reflect.deleteProperty(inputSchema, '$schema')
return [
{
name,
title: definition.title,
description: definition.description,
inputSchema,
annotations: {
readOnlyHint: definition.access === 'read',
destructiveHint:
name === 'goodbuddy_config_apply' ||
name === 'note_delete' ||
name === 'note_entry_delete'
}
const startedAt = performance.now()
try {
const customBindings = await this.getCustomMcpBindings(
token,
extra.signal
)
const storyGraphAvailable = await this.isStoryGraphAvailable(token)
const scopedTools = [...availableTools].filter(name => !name.startsWith('story_graph_') || storyGraphAvailable).flatMap(
(name): Tool[] => {
const definition = scopedDataToolByName.get(
name as ScopedDataToolName
)
if (!definition) {
return []
}
const inputSchema = z.toJSONSchema(
definition.inputSchema,
{ target: 'draft-7' }
) as Tool['inputSchema'] & { $schema?: string }
Reflect.deleteProperty(inputSchema, '$schema')
return [
{
name,
title: definition.title,
description: definition.description,
inputSchema,
annotations: {
readOnlyHint: definition.access === 'read',
destructiveHint:
name === 'goodbuddy_config_apply' ||
name === 'note_delete' ||
name === 'note_entry_delete'
}
}
]
}
)
const capability = this.getCapability(token)
const imageTool = await imageToolDefinition(capability.imageToolBinding)
const browserTools = capability.browserConversationId && capability.browserTabId
? new BrowserModelTools({
service: this.browserService!,
conversationId: capability.browserConversationId,
browserTabId: capability.browserTabId
})
: undefined
const browserDefinitions = browserTools
? browserTools.listTools().map(
(definition): Tool => ({
name: definition.name,
title: definition.displayName,
description: definition.description,
inputSchema: definition.inputSchema as Tool['inputSchema'],
annotations: {
readOnlyHint:
definition.name === 'browser_snapshot' ||
definition.name === 'browser_screenshot',
destructiveHint: false
}
})
)
: []
session.listedTools = true
return {
tools: [
...(imageTool ? [{ name: imageTool.name, description: imageTool.description, inputSchema: imageTool.inputSchema as Tool['inputSchema'], annotations: { readOnlyHint: false, destructiveHint: false } }] : []),
...scopedTools,
...browserDefinitions,
...[...customBindings.values()].map(
(binding) => binding.exposedTool
)
]
}
)
const capability = this.getCapability(token)
const imageTool = await imageToolDefinition(capability.imageToolBinding)
const browserTools = capability.browserConversationId && capability.browserTabId
? new BrowserModelTools({
service: this.browserService!,
conversationId: capability.browserConversationId,
browserTabId: capability.browserTabId
})
: undefined
const browserDefinitions = browserTools
? browserTools.listTools().map(
(definition): Tool => ({
name: definition.name,
title: definition.displayName,
description: definition.description,
inputSchema: definition.inputSchema as Tool['inputSchema'],
annotations: {
readOnlyHint:
definition.name === 'browser_snapshot' ||
definition.name === 'browser_screenshot',
destructiveHint: false
}
})
)
: []
session.listedTools = true
return {
tools: [
...(imageTool ? [{ name: imageTool.name, description: imageTool.description, inputSchema: imageTool.inputSchema as Tool['inputSchema'], annotations: { readOnlyHint: false, destructiveHint: false } }] : []),
...scopedTools,
...browserDefinitions,
...[...customBindings.values()].map(
(binding) => binding.exposedTool
)
]
} catch (error) {
this.observeMcpFailure({
phase: 'tool-discovery', category: 'handler-failure',
elapsedMs: Math.round(performance.now() - startedAt),
correlationId: `sha256:${createHash('sha256').update(token).digest('hex')}`
})
throw error
}
}
)
@@ -1921,7 +1962,8 @@ export class KnowledgeMcpGateway {
private async handleRequest(
request: IncomingMessage,
response: ServerResponse
response: ServerResponse,
context: { phase: DesktopMcpDiagnosticMetadata['phase']; startedAt: number; correlationId?: string }
): Promise<void> {
if (request.url !== '/mcp') {
sendJson(response, 404, { error: 'Not found' })
@@ -1940,6 +1982,8 @@ export class KnowledgeMcpGateway {
})
return
}
context.phase = 'authentication'
context.startedAt = performance.now()
const authorization = request.headers.authorization
if (
typeof authorization !== 'string' ||
@@ -1949,6 +1993,7 @@ export class KnowledgeMcpGateway {
return
}
const token = authorization.slice('Bearer '.length)
context.correlationId = `sha256:${createHash('sha256').update(token).digest('hex')}`
try {
this.getCapability(token)
} catch {
@@ -1956,6 +2001,8 @@ export class KnowledgeMcpGateway {
return
}
context.phase = 'request'
context.startedAt = performance.now()
let body: unknown
if (request.method === 'POST') {
try {
@@ -1971,6 +2018,8 @@ export class KnowledgeMcpGateway {
}
}
context.phase = 'session'
context.startedAt = performance.now()
const sessionId = request.headers['mcp-session-id']
let createdSession = false
let session =
@@ -2020,15 +2069,21 @@ export class KnowledgeMcpGateway {
const availableTools = new Set(
this.getAvailableToolNames(token)
)
context.phase = 'initialize'
context.startedAt = performance.now()
session = this.createDownstreamMcpSession(
token,
availableTools
)
createdSession = true
this.downstreamMcpSessions.set(session.registryKey, session)
await session.mcp.connect(session.transport)
}
try {
if (createdSession) await session.mcp.connect(session.transport)
if (body && typeof body === 'object' && 'method' in body && body.method === 'tools/list') {
context.phase = 'tool-discovery'
context.startedAt = performance.now()
}
await session.transport.handleRequest(request, response, body)
} finally {
if (createdSession && session.id === undefined) {
@@ -1,5 +1,6 @@
// @vitest-environment node
import { existsSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { mkdtemp, rm } from 'node:fs/promises'
import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
@@ -8,12 +9,112 @@ import { createOpencodeClient } from '@opencode-ai/sdk/v2'
import { expect, it } from 'vitest'
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
import { OpenCodeRuntime } from './opencode-runtime'
import type { DesktopDiagnosticFailure } from '../desktop-diagnostics'
const binaryPath = join(
process.cwd(), '.runtime-resources', process.arch,
process.platform === 'win32' ? 'opencode.exe' : 'opencode'
)
it.skipIf(!existsSync(binaryPath))(
'recovers a lost MCP add response with the real OpenCode binary and gateway',
async () => {
const workspace = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-mcp-retry-'))
let modelCalls = 0
const model = createServer((_request, response) => {
modelCalls++
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.end(`data: ${JSON.stringify({
id: 'retry', object: 'chat.completion.chunk', created: 1, model: 'retry',
choices: [{ index: 0, delta: { role: 'assistant', content: 'RETRY_OK' }, finish_reason: 'stop' }]
})}\n\ndata: [DONE]\n\n`)
})
await new Promise<void>(resolve => model.listen(0, '127.0.0.1', resolve))
const address = model.address()
if (!address || typeof address === 'string') throw new Error('No model port')
const gateway = new KnowledgeMcpGateway({} as never)
await gateway.start()
const controller = new AbortController()
const requestId = crypto.randomUUID()
const token = gateway.grant(requestId, [crypto.randomUUID()], controller.signal)!
const records: DesktopDiagnosticFailure[] = []
const operations: string[] = []
const registrations: Array<{ name: string; config: { headers: { Authorization: string } } }> = []
const runtime = new OpenCodeRuntime({
embedded: true, binaryPath: '', bundledBinaryPath: binaryPath,
configPath: '', defaultWorkspace: workspace, knowledgeGateway: gateway,
observeFailure: failure => records.push(failure),
modelProfile: {
id: crypto.randomUUID(), name: 'Retry fixture', modelName: 'retry',
baseUrl: `http://127.0.0.1:${address.port}/v1`,
protocol: 'openai-chat-completions', authentication: 'none'
}
}, {
createClient: options => createOpencodeClient({
...options,
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const pathname = new URL(request.url).pathname
if (pathname === '/mcp' && request.method === 'POST') {
const registration = await request.clone().json() as typeof registrations[number]
registrations.push(registration)
const response = await fetch(request, init)
const status = await response.clone().json() as Record<string, { status: string }>
expect(status[registration.name]?.status).toBe('connected')
operations.push('add-connected')
if (registrations.length === 1) {
// The server and gateway are already connected when the response is lost.
await response.body?.cancel()
throw new TypeError('fetch failed', { cause: Object.assign(new Error('socket reset'), { code: 'ECONNRESET' }) })
}
return response
}
const response = await fetch(request, init)
if (pathname.endsWith('/disconnect')) {
expect(await response.clone().json()).toBe(true)
operations.push('disconnect-succeeded')
}
return response
}
})
})
const deadline = setTimeout(() => controller.abort(), 30_000)
try {
let text = ''
for await (const event of runtime.run({
requestId, conversationId: crypto.randomUUID(), workMode: 'ask',
prompt: 'Reply RETRY_OK without tools.', knowledgeCapabilityToken: token
}, controller.signal)) {
if (event.type === 'text') text += event.delta
}
expect(text).toBe('RETRY_OK')
expect(modelCalls).toBe(1)
expect(operations).toEqual(['add-connected', 'disconnect-succeeded', 'add-connected', 'disconnect-succeeded'])
expect(registrations).toHaveLength(2)
expect(registrations[1]).toEqual(registrations[0])
expect(registrations[0]?.config.headers.Authorization).toBe(`Bearer ${token}`)
expect(records).toHaveLength(1)
expect(records[0]?.mcp).toMatchObject({
phase: 'connect', category: 'transport-failure', attempt: 1, kind: 'builtin',
correlationId: `sha256:${createHash('sha256').update(token).digest('hex')}`
})
expect(JSON.stringify(records)).not.toContain(token)
const unauthorized = await fetch(gateway.getEndpoint()!, { method: 'POST' })
expect(unauthorized.status).toBe(401)
await unauthorized.body?.cancel()
} finally {
clearTimeout(deadline)
await runtime.dispose()
gateway.revoke(token)
await gateway.dispose()
model.closeAllConnections()
await new Promise<void>(resolve => model.close(() => resolve()))
await rm(workspace, { recursive: true, force: true })
}
},
60_000
)
it.skipIf(!existsSync(binaryPath))(
'closes real event streams after parallel runs and compaction without cancelling a peer',
async () => {
+376 -6
View File
@@ -1,4 +1,5 @@
import { EventEmitter } from "node:events";
import { createHash } from "node:crypto";
import { createServer } from "node:http";
import {
mkdir,
@@ -11,11 +12,12 @@ import {
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { PassThrough } from "node:stream";
import type { createOpencodeClient } from "@opencode-ai/sdk/v2";
import { createOpencodeClient } from "@opencode-ai/sdk/v2";
import type spawn from "cross-spawn";
import { describe, expect, it, vi } from "vitest";
import type { KnowledgeMcpGateway } from "./knowledge-mcp-gateway";
import type { RuntimeEvent } from "./runtime";
import { DesktopDiagnostics, type DesktopDiagnosticFailureObserver } from "../desktop-diagnostics";
import {
createTemporaryMcpName,
OpenCodeRuntime,
@@ -3220,6 +3222,370 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
await runtime.dispose();
});
it.each(["knowledge", "custom"].flatMap<{
kind: string; scenario: string; detail: string; attempts: number;
succeeds?: boolean; thrown?: boolean; status?: string; apiError?: boolean;
}>((kind) => [
{ kind, scenario: "retry success", detail: "MCP error -32001: Request timed out", attempts: 2, succeeds: true },
{ kind, scenario: "retry exhausted", detail: "connect ECONNREFUSED", attempts: 2 },
{ kind, scenario: "thrown connect error", detail: "connect ECONNRESET", attempts: 2, succeeds: true, thrown: true },
{ kind, scenario: "auth", detail: "HTTP 401 Unauthorized; ECONNRESET", attempts: 1 },
{ kind, scenario: "OAuth", detail: "OAuth error: request timed out", attempts: 1 },
{ kind, scenario: "protocol", detail: "Invalid protocol response; connection closed", attempts: 1 },
{ kind, scenario: "unknown", detail: "Unexpected initialization failure", attempts: 1 },
{ kind, scenario: "plain failed", detail: "failed", attempts: 1 },
{ kind, scenario: "needs auth", detail: "needs_auth", attempts: 1, status: "needs_auth" },
{ kind, scenario: "missing status", detail: "unknown", attempts: 1, status: "missing" },
{ kind, scenario: "API error", detail: "HTTP 403 Forbidden", attempts: 1, apiError: true },
]))("handles $kind MCP initialization: $scenario", async ({ kind, detail, attempts, succeeds, thrown, status, apiError }) => {
const setup = runClient([
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
]);
const add = vi.mocked(setup.client.mcp.add);
const disconnect = vi.mocked(setup.client.mcp.disconnect);
const secret = "request-private-capability";
const failure = `${detail}\nAuthorization: Bearer ${secret}\npassword=private-password\nhttps://user:private-password@example.com/ECONNRESET?token=private-query\n${"x".repeat(3_000)}`;
const fail = async (input?: { name?: string }) => {
if (thrown) throw new Error(failure);
if (apiError) return { error: { name: "UnknownError", data: { message: failure } }, response: { status: 403 } };
return { data: status === "missing" ? {} : {
[input!.name!]: { status: status ?? "failed", error: failure },
} };
};
// The SDK returns a map keyed by the registered name, not a top-level status.
if (succeeds) add.mockImplementationOnce(fail as never);
else add.mockImplementation(fail as never);
const gateway = {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
grantCustomMcp: vi.fn(() => secret),
prepareCustomMcpTools: vi.fn(async () => [{ name: "custom_tool" }]),
revoke: vi.fn(),
};
const observeFailure = vi.fn<DesktopDiagnosticFailureObserver>();
const runtime = embeddedRuntime(setup.client, {
observeFailure,
knowledgeGateway: gateway as unknown as KnowledgeMcpGateway,
mcpServers: kind === "custom" ? [{
id: "custom", name: "Custom", description: "", transport: "http",
url: "https://example.com/mcp", enabled: true, allowDynamicTools: false,
assignments: ["opencode"], secretConfigured: false,
}] : [],
});
const collect = async () => {
const events: RuntimeEvent[] = [];
for await (const event of runtime.run({
requestId: "request-1", conversationId: "conversation-1", prompt: "test",
workMode: kind === "custom" ? "execute" : "ask",
...(kind === "knowledge" ? { knowledgeCapabilityToken: secret } : {}),
}, new AbortController().signal)) events.push(event);
return events;
};
try {
if (succeeds) {
await expect(collect()).resolves.toContainEqual(expect.objectContaining({ type: "done" }));
expect(setup.session.promptAsync).toHaveBeenCalledOnce();
} else {
const error = await collect().catch((error: unknown) => error) as Error;
expect(error).toBeInstanceOf(Error);
expect(error.message).toContain(detail);
expect(error.message).not.toMatch(/request-private-capability|private-password|private-query/);
expect(error.message.length).toBeLessThan(2_100);
expect(setup.session.promptAsync).not.toHaveBeenCalled();
}
expect(add).toHaveBeenCalledTimes(attempts);
expect(disconnect).toHaveBeenCalledTimes(attempts);
expect(observeFailure).toHaveBeenCalledTimes(succeeds ? 1 : attempts);
for (const [index, [record]] of observeFailure.mock.calls.entries()) {
expect(record).toMatchObject({
component: "runtime", stage: "connect", code: "runtime.mcp.failed",
mcp: {
phase: "connect", attempt: index + 1, kind: kind === "knowledge" ? "builtin" : "custom",
correlationId: `sha256:${createHash("sha256").update(secret).digest("hex")}`,
elapsedMs: expect.any(Number),
category: apiError ? "http-rejection" : thrown ? "transport-failure" : "initialization-failure",
...(apiError ? { status: 403 } : {}),
},
});
expect(record.mcp!.elapsedMs).toBeGreaterThanOrEqual(0);
expect(JSON.stringify(record)).not.toMatch(/request-private-capability|private-password|private-query|example.com|ECONNRESET/);
expect((record.error as Error).message).toBe("Local MCP operation failed");
}
if (attempts === 2) {
expect(add.mock.calls[1]![0]).toEqual(add.mock.calls[0]![0]);
expect(disconnect.mock.invocationCallOrder[0]).toBeGreaterThan(add.mock.invocationCallOrder[0]!);
expect(disconnect.mock.invocationCallOrder[0]).toBeLessThan(add.mock.invocationCallOrder[1]!);
}
expect(add.mock.calls[0]![0]?.config).toMatchObject({
headers: { Authorization: `Bearer ${secret}` }, oauth: false,
});
if (kind === "custom") {
expect(gateway.grantCustomMcp).toHaveBeenCalledOnce();
expect(gateway.prepareCustomMcpTools).toHaveBeenCalledOnce();
expect(gateway.revoke).toHaveBeenCalledExactlyOnceWith(secret);
}
} finally {
await runtime.dispose();
}
});
it.each(["recovered", "exhausted", "cleanup-false", "cleanup-transport", "cleanup-timeout", "http-401", "http-503", "unknown"])(
"handles installed SDK fetch results: %s", async (scenario) => {
const setup = runClient([
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
]);
const token = "private-sdk-capability";
const cause = Object.assign(new Error(`socket reset\nAuthorization: Bearer ${token}\npassword=private-password\nhttps://user:private-password@example.com/private-path`), { code: "ECONNRESET" });
// A circular cause must not cause unbounded traversal or expose a stack.
Object.assign(cause, { cause });
const failure = new TypeError("fetch failed", { cause });
const order: string[] = [];
const signals: AbortSignal[] = [];
let adds = 0;
const sdk = createOpencodeClient({
baseUrl: "http://127.0.0.1:4096", throwOnError: false,
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init);
if (new URL(request.url).pathname.endsWith("/disconnect")) {
order.push("disconnect");
signals.push(request.signal);
if (scenario === "cleanup-transport") throw failure;
if (scenario === "cleanup-timeout") return new Promise<Response>(() => {});
return Response.json(scenario !== "cleanup-false");
}
order.push("add");
adds++;
if (scenario.startsWith("http-")) {
return Response.json({ name: "UnknownError", data: { message: `ECONNRESET password=private-password` } }, { status: Number(scenario.slice(5)) });
}
if (scenario === "unknown") throw new TypeError("fetch failed");
if (adds === 1 || scenario === "exhausted") throw failure;
const { name } = await request.json() as { name: string };
return Response.json({ [name]: { status: "connected" } });
},
});
Object.assign(setup.client, { mcp: sdk.mcp });
const add = vi.spyOn(sdk.mcp, "add");
const observeFailure = vi.fn<DesktopDiagnosticFailureObserver>();
const runtime = embeddedRuntime(setup.client, {
observeFailure,
knowledgeGateway: {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
} as unknown as KnowledgeMcpGateway,
});
const collect = async () => {
for await (const event of runtime.run({
requestId: "sdk-request", conversationId: "sdk-conversation", prompt: "test", workMode: "ask",
knowledgeCapabilityToken: token,
}, new AbortController().signal)) void event;
};
try {
if (scenario === "recovered") {
await collect();
expect(setup.session.promptAsync).toHaveBeenCalledOnce();
} else {
const error = await collect().catch((error: unknown) => error) as Error;
expect(error).toBeInstanceOf(Error);
expect(error.message).toContain(scenario === "unknown" ? "fetch failed" : "ECONNRESET");
expect(error.message).not.toMatch(/private-|example\.com|socket reset.*at /);
expect(error.message.length).toBeLessThan(2_100);
expect(setup.session.promptAsync).not.toHaveBeenCalled();
}
const first = await add.mock.results[0]!.value;
if (scenario.startsWith("http-")) {
expect(first.response.status).toBe(Number(scenario.slice(5)));
expect(observeFailure.mock.calls[0]![0].mcp).toMatchObject({ category: "http-rejection", status: Number(scenario.slice(5)) });
} else {
expect(first.response).toBeUndefined();
expect(first.error).toBeInstanceOf(TypeError);
if (scenario !== "unknown") expect(first.error).toBe(failure);
expect(observeFailure.mock.calls[0]![0].mcp).toMatchObject({ category: "transport-failure" });
}
expect(add).toHaveBeenCalledTimes(scenario === "recovered" || scenario === "exhausted" ? 2 : 1);
expect(order).toEqual(scenario === "recovered" || scenario === "exhausted"
? ["add", "disconnect", "add", "disconnect"]
: scenario.startsWith("cleanup-") ? ["add", "disconnect", "disconnect"] : ["add", "disconnect"]);
if (scenario === "cleanup-timeout") expect(signals.every(signal => signal.aborted)).toBe(true);
const records = observeFailure.mock.calls.map(([record]) => record);
expect(JSON.stringify(records)).not.toMatch(/private-|ECONNRESET|socket reset|example\.com/);
expect(records.every(record => record.mcp?.correlationId === `sha256:${createHash("sha256").update(token).digest("hex")}`)).toBe(true);
} finally {
await runtime.dispose();
}
},
);
it("persists a recovered MCP attempt even when the observer throws", async () => {
const directory = await mkdtemp(join(tmpdir(), "goodbuddy-mcp-diagnostics-"));
const diagnostics = new DesktopDiagnostics(directory);
const setup = runClient([
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
]);
const token = "private-correlation-token";
vi.mocked(setup.client.mcp.add).mockImplementationOnce((async (input: { name: string }) => ({
data: { [input.name]: { status: "failed", error: `connect ECONNRESET Authorization: Bearer ${token}` } },
})) as never);
const writes: Promise<void>[] = [];
const observer = vi.fn<DesktopDiagnosticFailureObserver>((failure) => {
writes.push(diagnostics.recordFailure(failure));
throw new Error("observer failed");
});
const runtime = embeddedRuntime(setup.client, {
observeFailure: observer,
knowledgeGateway: {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
} as unknown as KnowledgeMcpGateway,
});
try {
for await (const event of runtime.run({
requestId: "request-1", conversationId: "conversation-1", prompt: "test", workMode: "ask",
knowledgeCapabilityToken: token,
}, new AbortController().signal)) void event;
expect(setup.client.mcp.add).toHaveBeenCalledTimes(2);
expect(setup.session.promptAsync).toHaveBeenCalledOnce();
expect(observer).toHaveBeenCalledOnce();
await Promise.all(writes);
await diagnostics.dispose();
const restarted = new DesktopDiagnostics(directory);
try {
const records = await restarted.readRecent();
expect(records).toHaveLength(1);
expect(records[0]?.mcp).toMatchObject({
phase: "connect", attempt: 1, kind: "builtin",
correlationId: `sha256:${createHash("sha256").update(token).digest("hex")}`,
});
expect((await restarted.exportRecent()).toString()).not.toMatch(/private-|ECONNRESET|Authorization|127\.0\.0\.1/);
} finally { await restarted.dispose(); }
} finally {
await runtime.dispose();
await diagnostics.dispose();
await rm(directory, { recursive: true, force: true });
}
});
it.each(["reject", "error", "false", "timeout"])("does not retry or reuse the slot after MCP cleanup %s", async (failure) => {
const setup = runClient([
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
]);
const add = vi.mocked(setup.client.mcp.add);
add.mockImplementationOnce((async (input: { name: string }) => ({
data: { [input.name]: { status: "failed", error: "connect ECONNREFUSED" } },
})) as never);
const disconnect = vi.mocked(setup.client.mcp.disconnect);
if (failure === "reject") disconnect.mockRejectedValue(new Error("cleanup failed"));
if (failure === "error") disconnect.mockResolvedValue({ error: { message: "cleanup failed" } } as never);
if (failure === "false") disconnect.mockResolvedValue({ data: false } as never);
if (failure === "timeout") disconnect.mockImplementation((() => new Promise(() => {})) as never);
const observeFailure = vi.fn<DesktopDiagnosticFailureObserver>(() => { throw new Error("observer failed"); });
const runtime = embeddedRuntime(setup.client, {
observeFailure,
knowledgeGateway: {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
} as unknown as KnowledgeMcpGateway,
});
const collect = async (requestId: string) => {
for await (const event of runtime.run({
requestId, conversationId: "conversation-1", prompt: "test", workMode: "ask",
knowledgeCapabilityToken: requestId,
}, new AbortController().signal)) void event;
};
try {
await expect(collect("first")).rejects.toThrow("ECONNREFUSED");
expect(add).toHaveBeenCalledOnce();
expect(setup.session.promptAsync).not.toHaveBeenCalled();
expect(observeFailure).toHaveBeenCalledTimes(3);
expect(observeFailure.mock.calls.slice(1).map(([record]) => record.mcp)).toEqual([
expect.objectContaining({ phase: "disconnect", attempt: 1, kind: "builtin", category: failure === "false" ? "cleanup-failure" : "transport-failure" }),
expect.objectContaining({ phase: "disconnect", attempt: 1, kind: "builtin" }),
]);
expect(new Set(observeFailure.mock.calls.map(([record]) => record.mcp?.correlationId)).size).toBe(1);
disconnect.mockResolvedValue({ data: true } as never);
await collect("second");
expect(add.mock.calls[1]![0]?.name).toBe(`${add.mock.calls[0]![0]?.name}-2`);
} finally {
await runtime.dispose();
}
});
it.each(["add", "disconnect", "retry"])("respects cancellation during MCP %s", async (phase) => {
const setup = runClient([]);
const controller = new AbortController();
const add = vi.mocked(setup.client.mcp.add);
add.mockImplementation((async (input: { name: string }) => {
if (phase === "add" || (phase === "retry" && add.mock.calls.length === 2)) {
controller.abort(new Error("cancel initialization"));
}
return { data: { [input.name]: { status: "failed", error: "connect ECONNRESET" } } };
}) as never);
const disconnect = vi.mocked(setup.client.mcp.disconnect);
disconnect.mockImplementation((async () => {
if (phase === "disconnect") controller.abort(new Error("cancel initialization"));
return { data: true };
}) as never);
const runtime = embeddedRuntime(setup.client, {
knowledgeGateway: {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
} as unknown as KnowledgeMcpGateway,
});
try {
await expect((async () => {
for await (const event of runtime.run({
requestId: "request-1", conversationId: "conversation-1", prompt: "test", workMode: "ask",
knowledgeCapabilityToken: "private-token",
}, controller.signal)) void event;
})()).rejects.toThrow("cancel initialization");
expect(add).toHaveBeenCalledTimes(phase === "retry" ? 2 : 1);
expect(disconnect).toHaveBeenCalledTimes(phase === "retry" ? 2 : 1);
expect(setup.session.promptAsync).not.toHaveBeenCalled();
} finally {
await runtime.dispose();
}
});
it("does not retry MCP initialization or the prompt after a transient tool execution error", async () => {
const setup = runClient([
{
id: "tool-error", type: "message.part.updated",
properties: {
sessionID: "session-1",
part: {
id: "part-1", callID: "call-1", type: "tool", tool: "knowledge_search",
state: {
status: "error", input: {}, error: "connect ECONNRESET",
time: { start: 1, end: 2 },
},
},
},
},
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
]);
const runtime = embeddedRuntime(setup.client, {
knowledgeGateway: {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
} as unknown as KnowledgeMcpGateway,
});
try {
const events: RuntimeEvent[] = [];
await expect((async () => {
for await (const event of runtime.run({
requestId: "request-1", conversationId: "conversation-1", prompt: "test", workMode: "ask",
knowledgeCapabilityToken: "private-token",
}, new AbortController().signal)) events.push(event);
})()).rejects.toThrow("connect ECONNRESET");
expect(events).toContainEqual(expect.objectContaining({ type: "tool", state: "failed" }));
expect(setup.client.mcp.add).toHaveBeenCalledOnce();
expect(setup.client.mcp.disconnect).toHaveBeenCalledOnce();
expect(setup.session.promptAsync).toHaveBeenCalledOnce();
} finally {
await runtime.dispose();
}
});
it("keeps temporary MCP names stable within a conversation and short enough for providers", async () => {
const setup = runClient([
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
@@ -3400,7 +3766,7 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
await runtime.dispose();
});
it("runs embedded conversations concurrently while serializing MCP registrations", async () => {
it.each([false, true])("runs embedded conversations concurrently while serializing MCP registrations (retry=%s)", async (retry) => {
const setup = runClient([
{
id: "idle",
@@ -3437,7 +3803,9 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
await firstAdd;
return {
data: {
[input.name]: { status: "connected" },
[input.name]: retry
? { status: "failed", error: "connect ECONNRESET" }
: { status: "connected" },
},
error: undefined,
};
@@ -3500,7 +3868,7 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
} finally {
resolveFirstAdd();
}
await vi.waitFor(() => expect(mcpAdd).toHaveBeenCalledTimes(2));
await vi.waitFor(() => expect(mcpAdd).toHaveBeenCalledTimes(retry ? 3 : 2));
await Promise.all([first, second]);
expect(
mcpAdd.mock.calls.map(
@@ -3511,8 +3879,10 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
}
).config.headers.Authorization,
),
).toEqual(["Bearer first-token", "Bearer second-token"]);
expect(setup.client.mcp.disconnect).toHaveBeenCalledTimes(2);
).toEqual(retry
? ["Bearer first-token", "Bearer first-token", "Bearer second-token"]
: ["Bearer first-token", "Bearer second-token"]);
expect(setup.client.mcp.disconnect).toHaveBeenCalledTimes(retry ? 3 : 2);
await runtime.dispose();
});
+99 -89
View File
@@ -56,7 +56,8 @@ import {
runtimePrivacyEnvironment,
} from "./process-environment";
import type { LaunchEnvironmentProvider } from "../local-tool-environment/launch-environment-provider";
import { boundedToolDetail, safeToolErrorDetail } from "./approval-summary";
import type { DesktopDiagnosticFailureObserver, DesktopMcpDiagnosticMetadata } from "../desktop-diagnostics";
import { boundedToolDetail, redactSensitiveText, safeToolErrorDetail } from "./approval-summary";
import type {
ResolvedMcpServer,
RuntimeSkillPackage,
@@ -503,6 +504,7 @@ export type OpenCodeRuntimeDependencies = {
};
export type OpenCodeRuntimeOptions = {
observeFailure?: DesktopDiagnosticFailureObserver;
baseUrl?: string;
embedded: boolean;
binaryPath: string;
@@ -2239,7 +2241,8 @@ export class OpenCodeRuntime implements AgentRuntime {
let knowledgeToolIds: string[] = [];
let customMcpName: string | undefined;
let customMcpToken: string | undefined;
const attemptedMcpNames: string[] = [];
type McpAttempt = Pick<DesktopMcpDiagnosticMetadata, "correlationId" | "kind" | "attempt">;
const attemptedMcpNames = new Map<string, McpAttempt>();
const mcpConversationKey = temporaryMcpConversationKey(
request.conversationId,
);
@@ -2255,6 +2258,96 @@ export class OpenCodeRuntime implements AgentRuntime {
? this.options.knowledgeGateway?.bindImageTool(request.imageToolBinding, signal, request.knowledgeCapabilityToken)
: undefined;
const scopedCapabilityToken = imageCapabilityToken ?? request.knowledgeCapabilityToken;
const observeMcpFailure = (mcp: DesktopMcpDiagnosticMetadata): void => {
try {
this.options.observeFailure?.({
component: "runtime", stage: mcp.phase, code: "runtime.mcp.failed",
error: new Error("Local MCP operation failed"), mcp,
});
} catch {
// Diagnostics must not affect retry, cancellation, or cleanup.
}
};
const disconnectMcp = async (name: string, metadata: McpAttempt): Promise<boolean> => {
// Called under the mutation lock; queue wait does not consume this budget.
const cleanupSignal = AbortSignal.timeout(1_000);
const startedAt = performance.now();
let category: DesktopMcpDiagnosticMetadata["category"] = "transport-failure";
let status: number | undefined;
try {
const result = await awaitWithAbort(
client.mcp.disconnect({ name, directory }, { signal: cleanupSignal }),
cleanupSignal,
);
if (result.data === true && !result.error) return true;
status = result.response?.status;
category = result.error && !result.response ? "transport-failure"
: status && status >= 400 ? "http-rejection" : "cleanup-failure";
} catch {
// The uncertain connection keeps its slot until cleanup succeeds.
}
observeMcpFailure({ ...metadata, phase: "disconnect", category, status,
elapsedMs: Math.round(performance.now() - startedAt) });
return false;
};
const addMcp = (name: string, url: string, token: string, label: string) =>
this.mutateMcp(signal, async () => {
for (let attempt = 0; attempt < 2; attempt++) {
signal.throwIfAborted();
const startedAt = performance.now();
const metadata: McpAttempt = {
correlationId: `sha256:${createHash("sha256").update(token).digest("hex")}`,
kind: name.startsWith(KNOWLEDGE_MCP_PREFIX) ? "builtin" : "custom",
attempt: attempt === 0 ? 1 : 2,
};
let failure: unknown;
let retryableStatus = true;
let category: DesktopMcpDiagnosticMetadata["category"] = "transport-failure";
let httpStatus: number | undefined;
try {
const added = await this.controlRequest(label, (controlSignal) => {
attemptedMcpNames.set(name, metadata);
return client.mcp.add({
directory, name,
config: {
type: "remote", url, enabled: true,
headers: { Authorization: `Bearer ${token}` },
oauth: false,
},
}, { signal: controlSignal });
}, signal);
const status = added.data?.[name];
httpStatus = added.response?.status;
const httpRejected = httpStatus !== undefined && httpStatus >= 400;
if (!httpRejected && !added.error && status?.status === "connected") return;
// With throwOnError:false the SDK resolves fetch failures without a response.
const transportFailure = !!added.error && !added.response;
category = transportFailure ? "transport-failure"
: httpRejected ? "http-rejection" : "initialization-failure";
retryableStatus = transportFailure || (!httpRejected && !added.error && status?.status === "failed");
failure = added.error ?? (status && "error" in status
? `${status.status}: ${status.error}`
: status?.status ?? "unknown");
} catch (error) {
failure = error;
}
observeMcpFailure({ ...metadata, phase: "connect",
category: signal.aborted ? "cancelled" : category, status: httpStatus,
elapsedMs: Math.round(performance.now() - startedAt) });
signal.throwIfAborted();
const failureDetail = safeToolErrorDetail(failure) ?? "unknown";
const detail = redactSensitiveText(
failureDetail.split(token).join("[REDACTED]"),
).replace(/https?:\/\/[^\s<>"']+/giu, "[URL]");
const error = new Error(`OpenCode ${label}失败(${httpStatus && httpStatus >= 400 ? `HTTP ${httpStatus}: ` : ""}${detail})`);
// Only explicit transport failures during initialization may be retried.
const transient = /\b(?:ECONNREFUSED|ECONNRESET|ETIMEDOUT|EPIPE)\b|\b(?:connection closed|socket hang up|request timed out)\b/iu.test(detail)
&& !/\b(?:auth(?:entication|orization)?\s+(?:failed|required|error|denied)|oauth|credentials|unauthorized|forbidden|401|403|protocol|invalid|parse|json|method)\b/iu.test(failureDetail);
if (attempt > 0 || !retryableStatus || !transient) throw error;
if (!await disconnectMcp(name, metadata)) throw error;
attemptedMcpNames.delete(name);
}
});
try {
if (
scopedCapabilityToken &&
@@ -2263,42 +2356,7 @@ export class OpenCodeRuntime implements AgentRuntime {
) {
const knowledgeEndpoint = this.options.knowledgeGateway.getEndpoint()!;
knowledgeMcpName = temporaryMcpName(KNOWLEDGE_MCP_PREFIX);
const added = await this.mutateMcp(signal, () =>
this.controlRequest(
"连接内置只读工具",
(controlSignal) => {
attemptedMcpNames.push(knowledgeMcpName!);
return client.mcp.add(
{
directory,
name: knowledgeMcpName,
config: {
type: "remote",
url: knowledgeEndpoint,
enabled: true,
headers: {
Authorization: `Bearer ${scopedCapabilityToken}`,
},
oauth: false,
},
},
{ signal: controlSignal },
);
},
signal,
),
);
if (added.error || !added.data) {
throw new Error(
opencodeErrorMessage(added.error, "OpenCode 内置只读工具连接失败"),
);
}
const addedStatus = added.data[knowledgeMcpName];
if (!addedStatus || addedStatus.status !== "connected") {
throw new Error(
`OpenCode 内置只读工具连接失败(${addedStatus?.status ?? "unknown"})`,
);
}
await addMcp(knowledgeMcpName, knowledgeEndpoint, scopedCapabilityToken, "连接内置只读工具");
// OpenCode 1.18.x does not include dynamically added MCP tools in
// experimental/tool/ids. Its model tool namespace is deterministic:
// "<MCP server name>_<declared tool name>".
@@ -2325,42 +2383,7 @@ export class OpenCodeRuntime implements AgentRuntime {
signal,
);
customMcpName = temporaryMcpName(CUSTOM_MCP_PREFIX);
const added = await this.mutateMcp(signal, () =>
this.controlRequest(
"连接自定义 MCP 工具",
(controlSignal) => {
attemptedMcpNames.push(customMcpName!);
return client.mcp.add(
{
directory,
name: customMcpName,
config: {
type: "remote",
url: customMcpEndpoint,
enabled: true,
headers: {
Authorization: `Bearer ${customMcpToken}`,
},
oauth: false,
},
},
{ signal: controlSignal },
);
},
signal,
),
);
const addedStatus = added.data?.[customMcpName];
if (
added.error ||
!added.data ||
!addedStatus ||
addedStatus.status !== "connected"
) {
throw new Error(
`OpenCode 自定义 MCP 连接失败(${addedStatus?.status ?? "unknown"})`,
);
}
await addMcp(customMcpName, customMcpEndpoint, customMcpToken, "连接自定义 MCP 工具");
knowledgeToolIds.push(
...tools.map((tool) => `${customMcpName}_${tool.name}`),
);
@@ -3193,23 +3216,10 @@ export class OpenCodeRuntime implements AgentRuntime {
subscriptionController.abort();
if (imageCapabilityToken && imageCapabilityToken !== request.knowledgeCapabilityToken) this.options.knowledgeGateway?.revoke(imageCapabilityToken);
let allMcpDisconnected = true;
for (const name of attemptedMcpNames) {
for (const [name, metadata] of attemptedMcpNames) {
const disconnected = await this.mutateMcp(
new AbortController().signal,
() => {
// Queue wait must not consume the disconnect timeout.
const cleanupSignal = AbortSignal.timeout(1_000);
return awaitWithAbort(
client.mcp.disconnect(
{ name, directory },
{ signal: cleanupSignal },
),
cleanupSignal,
);
},
).then(
(result) => !(result as { error?: unknown } | undefined)?.error,
() => false,
() => disconnectMcp(name, metadata),
);
allMcpDisconnected &&= disconnected;
}
@@ -44,6 +44,34 @@ afterEach(async () => {
})
describe('ApplicationSettingsStore', () => {
it.each([11, 12])('defaults missing frosted glass to off in version %s', async (version) => {
const { filePath, store } = await createStore()
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null, checkUpdatesOnStartup: false }
delete legacy.transparentFrostedEffectEnabled
await writeFile(filePath, JSON.stringify(legacy))
expect(await store.get()).toEqual({ ...defaultApplicationSettings, checkUpdatesOnStartup: false })
expect(applicationSettingsSchema.parse({ ...defaultApplicationSettings, transparentFrostedEffectEnabled: undefined }).transparentFrostedEffectEnabled).toBe(false)
})
it('persists and publishes both frosted glass choices across unrelated updates and reloads', async () => {
const { filePath, store } = await createStore()
expect((await store.get()).transparentFrostedEffectEnabled).toBe(false)
const changed = vi.fn()
store.onChanged(changed)
for (const transparentFrostedEffectEnabled of [true, false]) {
const saved = await store.update({ transparentFrostedEffectEnabled })
expect(saved.transparentFrostedEffectEnabled).toBe(transparentFrostedEffectEnabled)
expect(changed).toHaveBeenLastCalledWith(saved)
await store.update({ checkUpdatesOnStartup: false })
expect((await createApplicationSettingsStore(filePath).get()).transparentFrostedEffectEnabled).toBe(transparentFrostedEffectEnabled)
expect(JSON.parse(await readFile(filePath, 'utf8')).transparentFrostedEffectEnabled).toBe(transparentFrostedEffectEnabled)
}
expect(applicationSettingsUpdateSchema.parse({ checkUpdatesOnStartup: false })).toEqual({ checkUpdatesOnStartup: false })
for (const transparentFrostedEffectEnabled of ['false', 0, null]) {
await expect(store.update({ transparentFrostedEffectEnabled })).rejects.toThrow()
}
})
it.each([11, 12])('defaults missing desktop notifications to on in version %s', async (version) => {
const { filePath, store } = await createStore()
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null, checkUpdatesOnStartup: false }
+4
View File
@@ -251,6 +251,7 @@ export const resolveLegacyLocalToolEnvironmentPaths: LegacyLocalToolEnvironmentR
export const defaultApplicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -291,6 +292,7 @@ export class ApplicationSettingsStore {
StoredApplicationSettings,
| 'conversationHtmlRenderingEnabled'
| 'desktopNotificationsEnabled'
| 'transparentFrostedEffectEnabled'
| 'localToolEnvironment'
| 'version'
| 'applicationNavigation'
@@ -526,6 +528,7 @@ export class ApplicationSettingsStore {
return {
checkUpdatesOnStartup: stored.checkUpdatesOnStartup,
desktopNotificationsEnabled: stored.desktopNotificationsEnabled,
transparentFrostedEffectEnabled: stored.transparentFrostedEffectEnabled,
updateSource: stored.updateSource,
modelDownloadSource: stored.modelDownloadSource,
localToolEnvironment: stored.localToolEnvironment,
@@ -572,6 +575,7 @@ export class ApplicationSettingsStore {
const settings: ApplicationSettings = {
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
desktopNotificationsEnabled: next.desktopNotificationsEnabled,
transparentFrostedEffectEnabled: next.transparentFrostedEffectEnabled,
updateSource: next.updateSource,
modelDownloadSource: next.modelDownloadSource,
localToolEnvironment: next.localToolEnvironment,
@@ -3709,6 +3709,8 @@ describe('AssistantDatabase', () => {
ALTER TABLE tasks DROP COLUMN current_user_message_id;
ALTER TABLE tasks DROP COLUMN remote_recoverable;
DROP INDEX task_events_remote_provenance_unique;
DROP INDEX task_events_remote_task_idx;
DROP INDEX task_events_lifecycle_idx;
ALTER TABLE task_events DROP COLUMN remote_event_index;
ALTER TABLE task_events DROP COLUMN remote_semantic_sequence;
ALTER TABLE task_events DROP COLUMN remote_operation_id;
+15 -1
View File
@@ -133,7 +133,7 @@ import {
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
} from './subagent-progress-storage'
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 51
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 52
export type RemoteTaskEventInput = {
taskId: string
@@ -11895,6 +11895,20 @@ export class AssistantDatabase {
database.exec('PRAGMA user_version = 51; COMMIT;')
} catch (error) { database.exec('ROLLBACK'); throw error }
}
if (version.user_version < 52) {
database.exec('BEGIN IMMEDIATE')
try {
// Execution stats poll every few seconds. Filtering lifecycle and remote
// rows through task_events_task_idx reads every large stream payload row.
database.exec(`
CREATE INDEX IF NOT EXISTS task_events_lifecycle_idx
ON task_events(task_id, id) WHERE kind IN ('status', 'done', 'error');
CREATE INDEX IF NOT EXISTS task_events_remote_task_idx
ON task_events(task_id) WHERE remote_operation_id IS NOT NULL;
`)
database.exec('PRAGMA user_version = 52; COMMIT;')
} catch (error) { database.exec('ROLLBACK'); throw error }
}
}
supervisionReviewStore(): SupervisionReviewStore {
@@ -427,9 +427,12 @@ describe('execution duration evidence', () => {
const pairMs = (performance.now() - start) / 5
const query = prepare.mock.calls.find(([sql]) => sql.includes('WITH scoped AS MATERIALIZED'))![0]
const plan = raw.prepare(`EXPLAIN QUERY PLAN ${query}`).all(projectId).map(row => row.detail as string)
for (const alias of ['proof', 'remote', 'tail', 'previous', 'e']) {
for (const alias of ['proof', 'tail', 'previous']) {
expect(plan.some(detail => detail.includes(`SEARCH ${alias} USING INDEX task_events_task_idx`))).toBe(true)
}
// Filtering these through task_events_task_idx would read every payload row.
expect(plan.some(detail => detail.includes('SEARCH remote USING INDEX task_events_remote_task_idx'))).toBe(true)
expect(plan.some(detail => detail.includes('SEARCH e USING INDEX task_events_lifecycle_idx'))).toBe(true)
// Malformed bodies make accidental JSON extraction fail deterministically.
raw.prepare("UPDATE task_events SET payload_json = '{unparsed' WHERE task_id = ? AND kind IN ('text', 'tool')").run(id)
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 5000, requestCount: 1, incompleteRequestCount: 1 })
+46
View File
@@ -34,6 +34,52 @@ afterEach(async () => {
})
describe('DesktopDiagnostics', () => {
it('gates MCP metadata and discards unknown or unsafe fields', () => {
const record = { timestamp: '2026-09-30T00:00:00Z', component: 'runtime', stage: 'connect', code: 'runtime.mcp.failed' }
const mcp = { phase: 'initialize', category: 'http-rejection', elapsedMs: 25, status: 406, correlationId: `sha256:${'a'.repeat(64)}`, token: 'private-token', headers: { authorization: 'private-token' } }
expect(normalizeDesktopDiagnosticRecord({ ...record, mcp })?.mcp).toEqual({ phase: 'initialize', category: 'http-rejection', elapsedMs: 25, status: 406, correlationId: mcp.correlationId })
expect(normalizeDesktopDiagnosticRecord({ ...record, code: 'runtime.run.failed', mcp })).not.toHaveProperty('mcp')
expect(normalizeDesktopDiagnosticRecord({ ...record, component: 'desktop', mcp })).not.toHaveProperty('mcp')
for (const invalid of [{ phase: 'private-url' }, { category: 'private-body' }, { elapsedMs: -1 }, { elapsedMs: Infinity }, { elapsedMs: 1.5 }]) {
expect(normalizeDesktopDiagnosticRecord({ ...record, mcp: { ...mcp, ...invalid } })).not.toHaveProperty('mcp')
}
expect(normalizeDesktopDiagnosticRecord({ ...record, mcp: { ...mcp, status: 999, correlationId: 'raw-token' } })?.mcp)
.toEqual({ phase: 'initialize', category: 'http-rejection', elapsedMs: 25 })
})
it('renormalizes stored MCP metadata during export after restart', async () => {
const directory = await temporaryDirectory()
const diagnostics = new DesktopDiagnostics(directory)
const mcp = { phase: 'tool-discovery' as const, category: 'handler-failure' as const, elapsedMs: 42, correlationId: `sha256:${'b'.repeat(64)}` }
await diagnostics.recordFailure({ component: 'runtime', stage: 'connect', code: 'runtime.mcp.failed', error: new Error('private-error'), mcp })
await diagnostics.dispose()
const path = join(directory, 'desktop-diagnostics.ndjson')
const stored = JSON.parse(await readFile(path, 'utf8'))
expect(stored.mcp).toEqual(mcp)
await writeFile(path, JSON.stringify({ ...stored, message: 'private-body', mcp: { ...mcp, token: 'private-token', url: 'private-url', stack: 'private-stack', status: 'private-status' } }) + '\n')
const restarted = new DesktopDiagnostics(directory)
try {
const exported = (await restarted.exportRecent()).toString('utf8')
expect(JSON.parse(exported).mcp).toEqual(mcp)
expect(exported).not.toContain('private-')
} finally { await restarted.dispose() }
})
it.each(['connect', 'disconnect'])('allowlists runtime MCP %s attempts', (phase) => {
const record = { timestamp: '2026-09-30T00:00:00Z', component: 'runtime', stage: phase, code: 'runtime.mcp.failed' }
const mcp = { phase, category: 'transport-failure', elapsedMs: 1000, attempt: 2, kind: 'custom', correlationId: `sha256:${'c'.repeat(64)}` }
expect(normalizeDesktopDiagnosticRecord({ ...record, mcp: { ...mcp, error: 'private-error', token: 'private-token' } })?.mcp).toEqual(mcp)
for (const category of ['initialization-failure', 'cleanup-failure', 'cancelled']) {
expect(normalizeDesktopDiagnosticRecord({ ...record, mcp: { ...mcp, category } })?.mcp?.category).toBe(category)
}
for (const attempt of [0, 3, 1.5, '1']) {
const normalized = normalizeDesktopDiagnosticRecord({ ...record, mcp: { ...mcp, attempt, kind: 'private-kind' } })?.mcp
expect(normalized).not.toHaveProperty('attempt')
expect(normalized).not.toHaveProperty('kind')
expect(normalized?.correlationId).toBe(mcp.correlationId)
}
})
it('preserves renderer exit details on disk and in exports after restart', async () => {
const directory = await temporaryDirectory()
const diagnostics = new DesktopDiagnostics(directory)
+37 -1
View File
@@ -35,6 +35,7 @@ const allowedCodes = new Set([
'desktop.startup.failed',
'runtime.operation.failed',
'runtime.run.failed',
'runtime.mcp.failed',
'remote.connection.network',
'remote.connection.host-invalidated',
'remote.connection.host-identity',
@@ -68,6 +69,35 @@ export type DesktopDiagnosticComponent =
| 'runtime'
| 'remote-agent'
export type DesktopMcpDiagnosticMetadata = Readonly<{
phase: 'request' | 'authentication' | 'session' | 'initialize' | 'tool-discovery' | 'connect' | 'disconnect'
category: 'http-rejection' | 'handler-failure' | 'transport-failure' | 'initialization-failure' | 'cleanup-failure' | 'cancelled'
elapsedMs: number
status?: number
correlationId?: string
attempt?: 1 | 2
kind?: 'builtin' | 'custom'
}>
function normalizeMcpMetadata(value: unknown): DesktopMcpDiagnosticMetadata | undefined {
if (!value || typeof value !== 'object') return undefined
const candidate = value as DesktopMcpDiagnosticMetadata
if (!['request', 'authentication', 'session', 'initialize', 'tool-discovery', 'connect', 'disconnect'].includes(candidate.phase) ||
!['http-rejection', 'handler-failure', 'transport-failure', 'initialization-failure', 'cleanup-failure', 'cancelled'].includes(candidate.category) ||
!Number.isSafeInteger(candidate.elapsedMs) || candidate.elapsedMs < 0) return undefined
return Object.freeze({
phase: candidate.phase,
category: candidate.category,
elapsedMs: candidate.elapsedMs,
...(candidate.attempt === 1 || candidate.attempt === 2 ? { attempt: candidate.attempt } : {}),
...(candidate.kind === 'builtin' || candidate.kind === 'custom' ? { kind: candidate.kind } : {}),
...(Number.isInteger(candidate.status) && candidate.status! >= 100 && candidate.status! <= 599
? { status: candidate.status } : {}),
...(typeof candidate.correlationId === 'string' && /^sha256:[a-f0-9]{64}$/.test(candidate.correlationId)
? { correlationId: candidate.correlationId } : {})
})
}
export type DesktopDiagnosticFailure = Readonly<{
component: DesktopDiagnosticComponent
stage: string
@@ -75,6 +105,7 @@ export type DesktopDiagnosticFailure = Readonly<{
error: unknown
reason?: string
exitCode?: number
mcp?: DesktopMcpDiagnosticMetadata
}>
export type DesktopDiagnosticFailureObserver = (
@@ -90,6 +121,7 @@ export type DesktopDiagnosticRecord = Readonly<{
message: string
reason?: string
exitCode?: number
mcp?: DesktopMcpDiagnosticMetadata
}>
export type DesktopDiagnosticsOptions = Readonly<{
@@ -178,6 +210,8 @@ export function normalizeDesktopDiagnosticRecord(
const component = safeComponent(candidate.component)
const stage = safeStage(candidate.stage)
const code = safeCode(candidate.code)
const mcp = component === 'runtime' && code === 'runtime.mcp.failed'
? normalizeMcpMetadata(candidate.mcp) : undefined
const rendererGone = component === 'desktop' && stage === 'renderer' &&
code === 'desktop.renderer.gone'
return Object.freeze({
@@ -187,6 +221,7 @@ export function normalizeDesktopDiagnosticRecord(
code,
errorType: safeStoredErrorType(candidate.errorType),
message: fixedMessage(component, stage),
...(mcp ? { mcp } : {}),
...(rendererGone && typeof candidate.reason === 'string' &&
rendererExitReasons.has(candidate.reason) ? { reason: candidate.reason } : {}),
...(rendererGone && Number.isSafeInteger(candidate.exitCode)
@@ -266,7 +301,8 @@ export class DesktopDiagnostics {
code: failure.code,
errorType: safeErrorType(failure.error),
reason: failure.reason,
exitCode: failure.exitCode
exitCode: failure.exitCode,
mcp: failure.mcp
})
if (normalized === undefined) {
return Promise.reject(
@@ -113,6 +113,23 @@ describe('FeedbackService', () => {
code: 'runtime.run.failed',
errorType: 'TimeoutError',
message: 'password=hunter2'
},
{
timestamp: '2026-08-25T01:02:04.000Z',
component: 'runtime' as const,
stage: 'connect',
code: 'runtime.mcp.failed',
errorType: 'Error',
message: 'private-mcp-message',
mcp: {
phase: 'tool-discovery' as const,
category: 'handler-failure' as const,
elapsedMs: 123,
correlationId: `sha256:${'a'.repeat(64)}`,
token: 'private-mcp-token',
headers: { authorization: 'private-mcp-auth' },
url: 'private-mcp-url'
}
}
])
const fixture = service(
@@ -146,6 +163,11 @@ describe('FeedbackService', () => {
'{"timestamp":"2026-08-25T01:02:03.000Z","component":"runtime","stage":"run","code":"runtime.run.failed","errorType":"TimeoutError","message":"Runtime request failed"}'
)
expect(payload.description).not.toContain('hunter2')
expect(payload.description).toContain(JSON.stringify({
phase: 'tool-discovery', category: 'handler-failure', elapsedMs: 123,
correlationId: `sha256:${'a'.repeat(64)}`
}))
expect(payload.description).not.toContain('private-mcp-')
expect(payload.description).toMatch(
/\n\[GOODBUDDY_DESKTOP_DIAGNOSTICS_V1_END\]$/u
)
+3
View File
@@ -1030,6 +1030,7 @@ if (hasSingleInstanceLock) {
const startupKnowledgeGateway = new KnowledgeMcpGateway(
startupKnowledgeService,
{
observeFailure: observeDesktopFailure,
storyGraphService: {
available: async ({ runtimeTarget }) => (await applicationSettingsStore.get()).heartbeatEnabled === true &&
(await capabilityService.getEnabledBuiltinMcpServerIds(runtimeTarget)).includes('story-graph'),
@@ -1078,6 +1079,7 @@ if (hasSingleInstanceLock) {
: Promise.resolve([])
])
return createAgentRuntime(defaultWorkspace, settings, {
observeFailure: observeDesktopFailure,
localRuntimeRegistry,
skillInstructions: skillContext.instructions,
skillPackages: skillContext.packages,
@@ -1495,6 +1497,7 @@ if (hasSingleInstanceLock) {
resourcesPath: app.isPackaged ? process.resourcesPath : undefined,
openExternal: (url) => shell.openExternal(url),
createGateway: () => new KnowledgeMcpGateway(startupKnowledgeService, {
observeFailure: observeDesktopFailure,
storyGraphService: {
available: async ({ runtimeTarget }) => (await applicationSettingsStore.get()).heartbeatEnabled === true &&
(await capabilityService.getEnabledBuiltinMcpServerIds(runtimeTarget)).includes('story-graph'),
+86
View File
@@ -1211,6 +1211,7 @@ function installRemoteProjectsSetting(enabled: boolean): {
let settings: ApplicationSettings = {
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github",
modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -3291,6 +3292,7 @@ describe("App", () => {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -3395,6 +3397,7 @@ describe("App", () => {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -3411,6 +3414,7 @@ describe("App", () => {
updateSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -3513,6 +3517,7 @@ describe("App", () => {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -3529,6 +3534,7 @@ describe("App", () => {
updateSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -5922,6 +5928,7 @@ describe("App", () => {
let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github",
modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -7835,6 +7842,77 @@ describe("App", () => {
expect(localStorage.getItem("goodbuddy.appearance-theme")).toBe("dark");
});
it('applies frosted glass only after saving and removes it when disabled', async () => {
const updates = api.updates!
const { container, unmount } = render(<App />)
const shell = container.querySelector('.app-shell')
expect(shell).not.toHaveAttribute('data-frosted-glass')
expect(document.documentElement).not.toHaveAttribute('data-frosted-glass')
fireEvent.click(await screen.findByRole('button', { name: '设置' }))
fireEvent.click(await screen.findByRole('tab', { name: '外观' }))
const toggle = screen.getByRole('switch', { name: '透明磨砂特效' })
expect(toggle.closest('.toggle-row')?.firstElementChild).toHaveTextContent('透明磨砂特效')
expect(toggle.closest('.toggle-row')?.lastElementChild).toBe(toggle)
await waitFor(() => expect(toggle).toBeEnabled())
const saved = { ...await updates.getSettings(), transparentFrostedEffectEnabled: true }
let resolveSave!: (settings: ApplicationSettings) => void
vi.mocked(updates.updateSettings).mockImplementationOnce(() => new Promise(resolve => { resolveSave = resolve }))
fireEvent.click(toggle)
expect(toggle).toBeDisabled()
expect(toggle).not.toBeChecked()
expect(shell).not.toHaveAttribute('data-frosted-glass')
expect(document.documentElement).not.toHaveAttribute('data-frosted-glass')
expect(updates.updateSettings).toHaveBeenLastCalledWith({ transparentFrostedEffectEnabled: true })
await act(async () => resolveSave(saved))
expect(toggle).toBeChecked()
expect(toggle).toBeEnabled()
expect(shell).toHaveAttribute('data-frosted-glass', 'true')
expect(document.documentElement).toHaveAttribute('data-frosted-glass', 'true')
fireEvent.click(toggle)
await waitFor(() => {
expect(toggle).not.toBeChecked()
expect(toggle).toBeEnabled()
expect(shell).not.toHaveAttribute('data-frosted-glass')
expect(document.documentElement).not.toHaveAttribute('data-frosted-glass')
})
expect(updates.updateSettings).toHaveBeenLastCalledWith({ transparentFrostedEffectEnabled: false })
fireEvent.click(toggle)
await waitFor(() => expect(shell).toHaveAttribute('data-frosted-glass', 'true'))
unmount()
expect(document.documentElement).not.toHaveAttribute('data-frosted-glass')
const reloaded = render(<App />)
await waitFor(() => expect(reloaded.container.querySelector('.app-shell')).toHaveAttribute('data-frosted-glass', 'true'))
expect(document.documentElement).toHaveAttribute('data-frosted-glass', 'true')
})
it('retains confirmed frosted glass after a failed save and allows rereading locked settings', async () => {
const updates = api.updates!
await updates.updateSettings({ transparentFrostedEffectEnabled: true })
const { container } = render(<App />)
fireEvent.click(await screen.findByRole('button', { name: '设置' }))
fireEvent.click(await screen.findByRole('tab', { name: '外观' }))
const toggle = screen.getByRole('switch', { name: '透明磨砂特效' })
await waitFor(() => {
expect(toggle).toBeEnabled()
expect(toggle).toBeChecked()
})
vi.mocked(updates.updateSettings).mockRejectedValueOnce(new Error('response lost'))
vi.mocked(updates.getSettings).mockRejectedValueOnce(new Error('read unavailable'))
fireEvent.click(toggle)
await screen.findByText('无法确认保存结果,请重新读取后再修改。')
expect(toggle).toBeChecked()
expect(toggle).toBeDisabled()
expect(container.querySelector('.app-shell')).toHaveAttribute('data-frosted-glass', 'true')
expect(document.documentElement).toHaveAttribute('data-frosted-glass', 'true')
fireEvent.click(screen.getByRole('button', { name: '重试' }))
await waitFor(() => expect(toggle).toBeEnabled())
fireEvent.click(toggle)
await waitFor(() => {
expect(toggle).not.toBeChecked()
expect(container.querySelector('.app-shell')).not.toHaveAttribute('data-frosted-glass')
})
})
it("loads token usage in activity and refreshes it when a run finishes", async () => {
vi.mocked(api.usage.getTokenSummary).mockResolvedValueOnce({
totals: {
@@ -13848,6 +13926,7 @@ describe("App", () => {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -13864,6 +13943,7 @@ describe("App", () => {
updateSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -13917,6 +13997,7 @@ describe("App", () => {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -13977,6 +14058,7 @@ describe("App", () => {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -14014,6 +14096,7 @@ describe("App", () => {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -14030,6 +14113,7 @@ describe("App", () => {
updateSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -14092,6 +14176,7 @@ describe("App", () => {
let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github",
modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -14151,6 +14236,7 @@ describe("App", () => {
let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github",
modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings,
+15 -1
View File
@@ -1996,6 +1996,14 @@ function App(): React.JSX.Element {
const [applicationMenuOpen, setApplicationMenuOpen] = useState(false);
const applicationMenuTriggerRef = useRef<HTMLButtonElement>(null);
const [applicationSettings, setApplicationSettings] = useState<ApplicationSettings>();
useLayoutEffect(() => {
// FloatingPortal surfaces live outside app-shell, including in the top layer.
delete document.documentElement.dataset.frostedGlass;
if (applicationSettings?.transparentFrostedEffectEnabled) {
document.documentElement.dataset.frostedGlass = 'true';
}
return () => { delete document.documentElement.dataset.frostedGlass; };
}, [applicationSettings?.transparentFrostedEffectEnabled]);
const [applicationSettingsPending, setApplicationSettingsPending] = useState(false);
const [applicationSettingsUnconfirmed, setApplicationSettingsUnconfirmed] = useState(false);
const applicationSettingsPendingRef = useRef(0);
@@ -8559,7 +8567,7 @@ function App(): React.JSX.Element {
};
return (
<div className="app-shell">
<div className="app-shell" data-frosted-glass={applicationSettings?.transparentFrostedEffectEnabled ? 'true' : undefined}>
<DocumentConversationContext value={{
activeId: activeConversation?.remote ? undefined : activeId,
create: () => new Promise((resolve, reject) => {
@@ -10971,6 +10979,12 @@ function App(): React.JSX.Element {
<Suspense fallback={null}>
<SettingsPanel
appearanceTheme={appearanceTheme}
transparentFrostedEffectEnabled={applicationSettings?.transparentFrostedEffectEnabled ?? false}
applicationSettingsPending={applicationSettingsPending}
applicationSettingsLocked={applicationSettingsUnconfirmed || !applicationSettings}
applicationSettingsError={applicationSettingsError}
onTransparentFrostedEffectEnabledChange={(enabled) => updateApplicationSettings({ transparentFrostedEffectEnabled: enabled })}
onRetryApplicationSettings={() => void reloadApplicationSettings()}
brandingFallbackLogo={
resolvedAppearanceTheme === "dark"
? goodbuddyDarkIcon
+79 -76
View File
@@ -104,6 +104,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
const { t } = useTranslation("app");
const headingId = `chat-heading-${conversation.id}`;
const scrollRef = useRef<HTMLElement>(null);
const contextRef = useRef<HTMLDivElement>(null);
const pinnedToBottomRef = useRef(scrollSnapshot?.pinnedToBottom ?? true);
const latestScrollSnapshotRef = useRef(scrollSnapshot);
const restorePendingRef = useRef(true);
@@ -130,6 +131,29 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
);
const hiddenMessageCount = visibleMessageStartIndex;
useLayoutEffect(() => {
const context = contextRef.current;
const scrollContainer = scrollRef.current;
const content = scrollContainer?.querySelector<HTMLElement>('.chat-content');
if (!active || !context || !scrollContainer || !content) return;
const measure = (): void => {
// The fixed context strip also needs space inside the scrolling content.
context.parentElement?.style.setProperty(
'--chat-context-height', `${context.getBoundingClientRect().height}px`,
);
};
measure();
if (typeof ResizeObserver !== 'function') return;
const observer = new ResizeObserver(() => {
measure();
if (restorePendingRef.current || !pinnedToBottomRef.current) return;
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'auto' });
});
observer.observe(context);
observer.observe(content);
return () => observer.disconnect();
}, [active]);
useEffect(() => {
if (!active || noteMessageNavigation?.conversationId !== conversation.id) return;
const element = messageArticleRefs.current.get(noteMessageNavigation.messageId);
@@ -232,29 +256,6 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
}
}, [active, conversation.messages, scrollSnapshot, visibleMessageCount]);
useLayoutEffect(() => {
if (!active || typeof ResizeObserver !== "function") {
return;
}
const scrollContainer = scrollRef.current;
const messageList =
scrollContainer?.querySelector<HTMLElement>(".message-list");
if (!scrollContainer || !messageList) {
return;
}
const observer = new ResizeObserver(() => {
if (restorePendingRef.current || !pinnedToBottomRef.current) {
return;
}
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: "auto",
});
});
observer.observe(messageList);
return () => observer.disconnect();
}, [active]);
useLayoutEffect(() => {
const previous = prependScrollPositionRef.current;
if (!previous) {
@@ -330,7 +331,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
<h1 className="sr-only" id={headingId}>
{t("chat.heading")}
</h1>
{taskStrip}
<div className="chat-history-context" ref={contextRef}>{taskStrip}</div>
<section
aria-labelledby={headingId}
className="chat"
@@ -338,59 +339,61 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
onScroll={updateScrollPosition}
ref={handleScrollRef}
>
{isUnusedConversation(conversation) && (
<div className="welcome">
<div className="welcome__badge">
<Sparkles size={18} />
<div className="chat-content">
{isUnusedConversation(conversation) && (
<div className="welcome">
<div className="welcome__badge">
<Sparkles size={18} />
</div>
<h2>{t("chat.welcome.title")}</h2>
<div className="quick-actions">
{quickActions.map((action) => (
<button
key={action.title}
onClick={() => onSetInput(action.prompt)}
type="button"
>
<span className="quick-actions__icon">
<FileText size={17} />
</span>
<strong>{action.title}</strong>
<small>{action.description}</small>
</button>
))}
</div>
</div>
<h2>{t("chat.welcome.title")}</h2>
<div className="quick-actions">
{quickActions.map((action) => (
<button
key={action.title}
onClick={() => onSetInput(action.prompt)}
type="button"
>
<span className="quick-actions__icon">
<FileText size={17} />
</span>
<strong>{action.title}</strong>
<small>{action.description}</small>
</button>
))}
</div>
</div>
)}
<ChatTimeline
onOpenImageModelSettings={onOpenImageModelSettings}
onReselectImageSources={onReselectImageSources}
onEditImage={onEditImage}
artifactById={artifactById}
conversationId={conversation.id}
hiddenMessageCount={hiddenMessageCount}
isUnusedConversation={isUnusedConversation(conversation)}
locale={locale}
messages={visibleMessages}
messageStartIndex={visibleMessageStartIndex}
onArticleRef={handleArticleRef}
onCopyMessage={onCopyMessage}
onAddToNote={onAddToNote}
onDownloadImage={onDownloadImage}
onOpenCitationContext={onOpenCitationContext}
onOpenCitationSource={onOpenCitationSource}
onOpenImage={onOpenImage}
onRespondApproval={onRespondApproval}
onRespondQuestion={onRespondQuestion}
onRetry={onRetry}
onRevealEarlier={revealEarlierMessages}
renderAssistantHtml={conversationHtmlRenderingEnabled}
retryContent={
conversation.messages.at(-2)?.role === "user"
? conversation.messages.at(-2)?.content
: undefined
}
totalMessageCount={conversation.messages.length}
/>
)}
<ChatTimeline
onOpenImageModelSettings={onOpenImageModelSettings}
onReselectImageSources={onReselectImageSources}
onEditImage={onEditImage}
artifactById={artifactById}
conversationId={conversation.id}
hiddenMessageCount={hiddenMessageCount}
isUnusedConversation={isUnusedConversation(conversation)}
locale={locale}
messages={visibleMessages}
messageStartIndex={visibleMessageStartIndex}
onArticleRef={handleArticleRef}
onCopyMessage={onCopyMessage}
onAddToNote={onAddToNote}
onDownloadImage={onDownloadImage}
onOpenCitationContext={onOpenCitationContext}
onOpenCitationSource={onOpenCitationSource}
onOpenImage={onOpenImage}
onRespondApproval={onRespondApproval}
onRespondQuestion={onRespondQuestion}
onRetry={onRetry}
onRevealEarlier={revealEarlierMessages}
renderAssistantHtml={conversationHtmlRenderingEnabled}
retryContent={
conversation.messages.at(-2)?.role === "user"
? conversation.messages.at(-2)?.content
: undefined
}
totalMessageCount={conversation.messages.length}
/>
</div>
</section>
{active && showScrollToBottom && (
<button
@@ -74,6 +74,7 @@ const todo: MagicTodoItem = {
const otherTodo: MagicTodoItem = { ...todo, id: '00000000-0000-4000-8000-000000000604', noteId: secondNoteId, noteTitle: second.title, title: '准备演示' }
const settings: ApplicationSettings = {
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings, applicationNavigation: defaultApplicationNavigation,
localInferenceEnabled: true, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false,
+32
View File
@@ -520,6 +520,7 @@ const removeEmbeddingModel = vi.fn(
let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -814,6 +815,7 @@ describe('SettingsPanel runtime files', () => {
applicationSettings = {
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -1549,6 +1551,36 @@ describe('SettingsPanel runtime files', () => {
).toBeInTheDocument()
})
it.each([
['zh-CN', '外观', '透明磨砂特效', '应用于顶栏和展开的运行待办卡片,保存后立即生效。'],
['en-US', 'Appearance', 'Frosted glass effect', 'Applies to the top bar and expanded runtime checklist. Takes effect as soon as it is saved.']
] as const)('exposes the frosted glass Switch under Appearance in %s', async (locale, category, label, description) => {
await changeUiLocale(locale)
const onChange = vi.fn(async () => true)
const props = {
...heartbeatSettingsProps,
open: true,
onClearLocalData: vi.fn(async () => {}),
onClose: vi.fn(),
onSaved: vi.fn(),
onTransparentFrostedEffectEnabledChange: onChange
}
const { rerender } = render(<SettingsPanel {...props} />)
fireEvent.click(screen.getByRole('tab', { name: category }))
const toggle = screen.getByRole('switch', { name: label })
expect(toggle).not.toBeChecked()
expect(toggle).toHaveAccessibleDescription(description)
expect(toggle.closest('label')).toHaveClass('toggle-row')
fireEvent.click(toggle)
expect(onChange).toHaveBeenCalledExactlyOnceWith(true)
rerender(<SettingsPanel {...props} transparentFrostedEffectEnabled applicationSettingsPending />)
expect(toggle).toBeChecked()
expect(toggle).toBeDisabled()
rerender(<SettingsPanel {...props} transparentFrostedEffectEnabled />)
fireEvent.click(toggle)
expect(onChange).toHaveBeenLastCalledWith(false)
})
it.each([
['zh-CN', '平台功能', '桌面通知'],
['en-US', 'Platform features', 'Desktop notifications']
+38
View File
@@ -235,6 +235,12 @@ type SettingsPanelProps = {
onExpertsChanged?: (experts: AssistantExpert[]) => void
onClearLocalData: () => Promise<void>
appearanceTheme?: AppearanceTheme
transparentFrostedEffectEnabled?: boolean
applicationSettingsPending?: boolean
applicationSettingsLocked?: boolean
applicationSettingsError?: string
onTransparentFrostedEffectEnabledChange?: (enabled: boolean) => Promise<boolean>
onRetryApplicationSettings?: () => void
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
brandingPreferences?: BrandingPreferences
brandingFallbackLogo?: string
@@ -648,6 +654,12 @@ export function SettingsPanel({
onClearLocalData,
onExpertsChanged = () => {},
appearanceTheme = 'system',
transparentFrostedEffectEnabled = false,
applicationSettingsPending = false,
applicationSettingsLocked = false,
applicationSettingsError,
onTransparentFrostedEffectEnabledChange,
onRetryApplicationSettings,
onAppearanceThemeChange = () => {},
brandingPreferences = defaultBrandingPreferences,
brandingFallbackLogo,
@@ -2340,6 +2352,32 @@ export function SettingsPanel({
)}
</div>
</div>
<div className="settings-section">
<label className="toggle-row">
<span>{t('appearance.frostedGlass.label')}</span>
<input
type="checkbox"
role="switch"
checked={transparentFrostedEffectEnabled}
disabled={applicationSettingsPending || applicationSettingsLocked || !onTransparentFrostedEffectEnabledChange}
aria-describedby="frosted-glass-description"
onChange={(event) => {
void onTransparentFrostedEffectEnabledChange?.(event.target.checked)
}}
/>
</label>
<p id="frosted-glass-description" className="settings-panel__description">
{t('appearance.frostedGlass.description')}
</p>
{applicationSettingsError && (
<div role="alert">
<p>{applicationSettingsError}</p>
<button className="secondary-button" disabled={applicationSettingsPending} onClick={onRetryApplicationSettings} type="button">
{t('appearance.frostedGlass.retry')}
</button>
</div>
)}
</div>
<div className="settings-section appearance-settings">
<div className="settings-section__title">
<span className="inline-help-label">
@@ -24,6 +24,7 @@ describe('UpdateSettingsSection', () => {
let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -105,6 +105,9 @@ describe('WorkspacePrimitives', () => {
expect(toggleRowStyles).toMatch(/align-items:\s*center;/u)
expect(toggleRowStyles).not.toMatch(/border-top:/u)
expect(toggleRowStyles).not.toMatch(/padding-top:/u)
expect(stylesheet).toMatch(
/\.toggle-row > input\[role='switch'\]\s*\{[^}]*order:\s*1;[^}]*margin-inline-start:\s*auto;/u
)
})
it('keeps Platform Features tabs visible in scrollable Settings', () => {
@@ -558,7 +561,7 @@ describe('WorkspacePrimitives', () => {
/\.settings-backdrop \.settings-tabs button strong\s*\{[^}]*font-size:\s*var\(--font-body\);/u
)
expect(stylesheet).toMatch(
/\.settings-backdrop \.settings-tabs button small\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
/\.settings-tabs__group-label\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
)
})
@@ -891,6 +891,11 @@ export const settings = {
}
},
appearance: {
frostedGlass: {
label: 'Frosted glass effect',
description: 'Applies to the top bar and expanded runtime checklist. Takes effect as soon as it is saved.',
retry: 'Retry'
},
theme: {
title: 'Interface theme',
description: 'Applies immediately and is saved on this device',
@@ -895,7 +895,7 @@ export const settingsSections = {
diagnostics: {
label: 'Attach recent desktop diagnostics',
description:
'Off by default. When selected, this adds only timestamps, components, stages, stable error codes, error types, and fixed short messages from recent desktop diagnostics. It does not include conversations, prompts, credentials, file contents, paths, raw provider responses, or remote Agent logs.'
'Off by default. When selected, this adds timestamps, components, stages, error codes, error types, and fixed short messages from recent desktop diagnostics, plus tool connection failure categories, timing, HTTP status, attempt counts, tool kinds, and anonymous correlation IDs. It does not include conversations, prompts, credentials, file contents, paths, raw provider responses, or remote Agent logs.'
},
screenshot: {
title: 'Screenshot (optional)',
@@ -826,6 +826,11 @@ export const settings = {
}
},
appearance: {
frostedGlass: {
label: '透明磨砂特效',
description: '应用于顶栏和展开的运行待办卡片,保存后立即生效。',
retry: '重试'
},
theme: {
title: '界面主题',
description: '选择后立即应用,并保存在此设备',
@@ -840,7 +840,7 @@ export const settingsSections = {
diagnostics: {
label: '附加最近桌面诊断记录',
description:
'默认关闭。选中后仅附加最近桌面诊断的时间、组件、阶段、稳定错误码、错误类型和固定短消息;不包含对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。'
'默认关闭。选中后附加最近桌面诊断的时间、组件、阶段、错误码、错误类型和固定短消息,以及工具连接失败的类别、耗时、HTTP 状态、尝试次数、工具类型和匿名关联标识;不包含对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。'
},
screenshot: {
title: '截图(可选)',
+6
View File
@@ -74,6 +74,12 @@
font-size: var(--font-caption);
}
.runtime-checklist__content ol { margin: 0; padding: var(--space-2); list-style: none; }
:root[data-frosted-glass='true'] .runtime-checklist__content {
background: var(--surface-glass-panel);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
color: var(--text-primary);
}
.runtime-checklist__content li {
display: flex;
align-items: baseline;
+45 -1
View File
@@ -5,6 +5,9 @@
--surface-raised: #fff;
--surface-subtle: #f4f8fb;
--surface-muted: #e5edf3;
--surface-glass: rgb(255 255 255 / 65%);
--surface-glass-panel: rgb(255 255 255 / 75%);
--border-glass: rgb(116 138 159 / 16%);
--text-primary: #17263a;
--text-secondary: #4f6278;
--text-muted: #60748a;
@@ -4591,6 +4594,30 @@ textarea:focus-visible {
-webkit-app-region: no-drag;
}
.app-shell[data-frosted-glass='true'] .topbar {
position: relative;
z-index: 3;
background: var(--surface-glass);
border-bottom-color: var(--border-glass);
backdrop-filter: blur(18px);
}
/* Extend only the active chat; the workbar and other routes keep their grid row. */
.app-shell[data-frosted-glass='true'] .workspace:has(> [data-route='chat']:not([hidden]) .chat-history-pane[data-active='true']) {
margin-top: -58px;
}
.app-shell[data-frosted-glass='true'] .chat-history-context {
position: absolute;
z-index: 2;
top: 58px;
width: 100%;
}
.app-shell[data-frosted-glass='true'] .chat-content {
padding-top: calc(58px + var(--chat-context-height, 0px) + var(--page-gutter));
}
/*
* Overlays are portalled to document.body and cover the whole window, so they
* paint over the .brand and .topbar drag regions. Without no-drag the native
@@ -6216,10 +6243,16 @@ button > svg {
}
.chat-history-pane {
position: relative;
height: 100%;
min-height: 0;
}
.chat-history-context {
flex: 0 0 auto;
min-height: 0;
}
.chat-history-pane[hidden] {
display: none;
}
@@ -6253,6 +6286,14 @@ button > svg {
scrollbar-width: thin;
}
.chat-history-pane > .chat {
padding: 0;
}
.chat-content {
padding: var(--page-gutter) 0 10px;
}
.chat-scroll-to-bottom {
position: absolute;
z-index: 2;
@@ -6383,7 +6424,7 @@ button > svg {
line-height: 1.5;
}
.chat > .welcome,
.chat-content > .welcome,
.message-list {
/* Keep the scrollbar inside the outer gutter without adding trailing scroll padding. */
width: calc(100cqw - 2 * var(--chat-inline-padding));
@@ -17313,6 +17354,9 @@ textarea.field-control {
--surface-raised: #0d1d2c;
--surface-subtle: #12283a;
--surface-muted: #1a3448;
--surface-glass: rgb(7 16 31 / 70%);
--surface-glass-panel: rgb(7 16 31 / 75%);
--border-glass: rgb(143 166 185 / 18%);
--text-primary: #edf7ff;
--text-secondary: #b5c6d5;
--text-muted: #8fa6b9;
@@ -59,6 +59,7 @@ const applicationPreferencesSchema = z
.object({
checkUpdatesOnStartup: z.boolean(),
desktopNotificationsEnabled: z.boolean().default(true),
transparentFrostedEffectEnabled: z.boolean().default(false),
updateSource: updateSourceSchema,
modelDownloadSource: modelDownloadSourceSchema,
localToolEnvironment: localToolEnvironmentSettingsSchema,
@@ -90,6 +91,7 @@ export const applicationSettingsUpdateSchema = applicationPreferencesSchema
.extend({
applicationNavigation: applicationNavigationSchema.optional(),
desktopNotificationsEnabled: z.boolean().optional(),
transparentFrostedEffectEnabled: z.boolean().optional(),
localInferenceEnabled: z.boolean().optional(),
magicNotesEnabled: z.boolean().optional(),
heartbeatEnabled: z.boolean().optional(),
@@ -153,6 +153,7 @@ describe('GoodBuddy configuration contracts', () => {
application: {
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,