Refine desktop UI and custom Agent workflows

Improve navigation, Agent management, onboarding, attachments, message actions, and visual consistency across the desktop UI. Add the validated Electron IPC, persistence, provider, readiness, and test support required by custom Agents and persistent message deletion.\n\nChecks: npm --prefix frontend test (161 passed); npm --prefix desktop test (351 passed); npm --prefix frontend run build; npm --prefix frontend run build:desktop; git diff --check.
This commit is contained in:
Ryder Sun committed 2026-08-03 13:26:49 +08:00
1 parent 3745aa3ac6
commit 5c9516c4d5
33 files changed
+3685 -292

No files matched your search

+1 -1
View File
@@ -72,7 +72,7 @@ The point is not to use more agents. The point is to use the right agent for eac
Meldwork currently connects to:
**Codex · Hermes · OpenClaw · WorkBuddy · Kimi Code · MiMo Code · Claude Code · Gemini CLI · OpenCode · Qwen Code**
**Codex · Hermes · OpenClaw · WorkBuddy · Kimi Code · MiMo Code · Claude Code · Gemini CLI · OpenCode · Qwen Code · OpenCodeReview**
Each agent keeps its own capabilities, provider configuration, permission model, and session behavior. Availability depends on the installed agent, its version, and your configuration.
+1 -1
View File
@@ -72,7 +72,7 @@ Meldwork 坚持本地优先。工作区访问是明确的,兼容的敏感凭
Meldwork 当前可连接:
**Codex · Hermes · OpenClaw · WorkBuddy · Kimi Code · MiMo Code · Claude Code · Gemini CLI · OpenCode · Qwen Code**
**Codex · Hermes · OpenClaw · WorkBuddy · Kimi Code · MiMo Code · Claude Code · Gemini CLI · OpenCode · Qwen Code · OpenCodeReview**
每个 Agent 仍然保留自己的能力、Provider 配置、权限模型和 Session 行为。实际可用性取决于已安装的 Agent、对应版本与用户配置。
+2 -1
View File
@@ -66,7 +66,7 @@ apiKey: API Key
API Key 仅通过 Electron `safeStorage` 加密保存到本机;操作系统安全存储不可用时拒绝落盘。
Provider 不设品牌、服务地址或模型默认值,也不会从远端自动获取配置。配置后可供 Hermes、
OpenClaw、WorkBuddy 和 Qwen 使用;其他 Agent 沿用各自的本机鉴权和 Provider 配置。
OpenClaw、WorkBuddy、Qwen 和 OpenCodeReview 使用;其他 Agent 沿用各自的本机鉴权和 Provider 配置。
## 本地 CLI 支持矩阵
@@ -82,6 +82,7 @@ OpenClaw、WorkBuddy 和 Qwen 使用;其他 Agent 沿用各自的本机鉴权
| Gemini CLI | `gemini` | npm:`@google/gemini-cli@latest` | npm:`@google/gemini-cli@latest` | 不注入;沿用 Gemini 本机配置 |
| OpenCode | `opencode` | npm:`opencode-ai@latest` | npm:`opencode-ai@latest` | 不注入;沿用 OpenCode 本机配置 |
| Qwen Code | `qwen` | npm:`@qwen-code/qwen-code@latest` | npm:`@qwen-code/qwen-code@latest` | 支持 |
| OpenCodeReview | `ocr` | npm:`@alibaba-group/open-code-review@latest` | npm:`@alibaba-group/open-code-review@latest` | 支持 |
macOS 会在当前 `PATH` 之外搜索 Volta、pnpm、fnm、asdf、mise、bun、
`~/.local/bin`、`~/.kimi-code/bin`、`~/.mimocode/bin`、Homebrew 等常见目录。Windows 会额外搜索用户 npm、
+5
View File
@@ -62,6 +62,10 @@ const AGENT_CATALOG = Object.freeze([
kind: 'qwen', label: 'Qwen Code', recommended: false, providerCompatible: true,
providerSupport: 'supported',
},
{
kind: 'opencodereview', label: 'OpenCodeReview', recommended: false, providerCompatible: true,
providerSupport: 'supported',
},
])
function installRecipe(kind, platform) {
@@ -106,6 +110,7 @@ function installRecipe(kind, platform) {
gemini: '@google/gemini-cli@latest',
opencode: 'opencode-ai@latest',
mimo: '@mimo-ai/cli@latest',
opencodereview: '@alibaba-group/open-code-review@latest',
}
return packages[kind] ? { type: 'npm', packageName: packages[kind] } : null
}
+13 -5
View File
@@ -21,6 +21,7 @@ const AGENT_PROFILES = {
gemini: { label: 'Gemini', commands: ['gemini'] },
opencode: { label: 'OpenCode', commands: ['opencode'] },
qwen: { label: 'Qwen', commands: ['qwen'] },
opencodereview: { label: 'OpenCodeReview', commands: ['ocr'] },
}
const ALLOWED_KINDS = Object.keys(AGENT_PROFILES)
const CODEX_SANDBOXES = new Set(['read-only', 'workspace-write'])
@@ -616,6 +617,16 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
promptArg: true,
}
}
if (kind === 'opencodereview') {
return {
command: executable,
args: [
'review', '--audience', 'agent', '--format', 'text', '--repo', workdir,
'--background',
],
promptArg: true,
}
}
throw new Error('LOCAL_AGENT_KIND_UNSUPPORTED')
}
@@ -2139,10 +2150,7 @@ async function runAcpAgent(agent, prompt, workdir, options, spec) {
const publicSessionRef = safeSessionRef.includes('[redacted]') ? '' : safeSessionRef
await connection.setSessionMode({ sessionId: sessionRef, modeId: spec.acpMode })
if (publicSessionRef && typeof options.onSessionRef === 'function') {
await options.onSessionRef(
publicSessionRef,
agent.kind === 'hermes' ? { transport: 'acp' } : undefined,
)
await options.onSessionRef(publicSessionRef, { transport: 'acp' })
}
replyState.bytes = 0
replyState.collecting = true
@@ -2242,7 +2250,7 @@ async function runAgent(agent, prompt, workdir, options = {}) {
const prepared = prepareCommand(spec.command, args, { platform })
const childEnv = childEnvironment(agent, workdir, options, platform)
const runtimeEvents = createRuntimeEventEmitter(options, childEnv)
if (['hermes', 'openclaw', 'workbuddy'].includes(agent.kind)) {
if (['hermes', 'openclaw', 'workbuddy', 'opencodereview'].includes(agent.kind)) {
runtimeEvents.emit({
id: `${agent.kind}-connector`,
type: 'warning',
+475
View File
@@ -0,0 +1,475 @@
const { execFile, spawn } = require('node:child_process')
const { randomUUID } = require('node:crypto')
const fs = require('node:fs')
const path = require('node:path')
const { promisify } = require('node:util')
const execFileAsync = promisify(execFile)
const CUSTOM_AGENT_KIND = /^custom-[a-f0-9]{16}$/
const PROMPT_MODES = new Set(['stdin', 'argument'])
const MAX_AGENTS = 32
const MAX_ARGUMENTS = 24
const MAX_ARGUMENT_LENGTH = 512
const MAX_ATTACHMENTS = 4
const MAX_PROMPT_BYTES = 1024 * 1024
const MAX_STDOUT_BYTES = 4 * 1024 * 1024
const MAX_STDERR_BYTES = 1024 * 1024
const TERMINATE_GRACE_MS = 500
const KILL_SETTLE_MS = 500
const VERSION_LINE_LIMIT = 160
const SENSITIVE_ARGUMENT = /api[-_]?key|token|secret|password|passwd|credential|authorization|cookie/i
const SECRET_VALUE = /(?:^|[=:])(?:sk|rk|pk|ghp|github_pat|xox[baprs]?)[_-][A-Za-z0-9_-]{12,}/i
const CHILD_ENV_KEYS = Object.freeze([
'HOME', 'USER', 'LOGNAME', 'SHELL', 'PATH',
'TMPDIR', 'TMP', 'TEMP',
'LANG', 'LANGUAGE', 'LC_ALL', 'LC_CTYPE', 'TZ',
'TERM', 'COLORTERM', 'NO_COLOR',
'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME',
'XDG_RUNTIME_DIR',
'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'USERNAME',
'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'PATHEXT',
'APPDATA', 'LOCALAPPDATA', 'PROGRAMDATA',
'PROGRAMFILES', 'PROGRAMFILES(X86)', 'PROGRAMW6432',
'OS', 'PROCESSOR_ARCHITECTURE', 'NUMBER_OF_PROCESSORS',
'SSL_CERT_FILE', 'SSL_CERT_DIR', 'NODE_EXTRA_CA_CERTS',
])
function customAgentError(code) {
return Object.assign(new Error(code), { code })
}
function cleanInline(value, limit) {
const text = String(value || '').trim().replace(/\s+/g, ' ')
if (!text || text.length > limit || /[\u0000-\u001f\u007f]/.test(text)) return ''
return text
}
function cleanDescription(value) {
const text = String(value || '').trim().replace(/\s+/g, ' ')
if (text.length > 240 || /[\u0000-\u001f\u007f]/.test(text)) return ''
return text
}
function normalizeArguments(value) {
if (value == null) return []
if (!Array.isArray(value) || value.length > MAX_ARGUMENTS) {
throw customAgentError('CUSTOM_AGENT_ARGUMENTS_INVALID')
}
return value.map((argument) => {
if (typeof argument !== 'string' || !argument.length
|| argument.length > MAX_ARGUMENT_LENGTH
|| /[\u0000\r\n]/.test(argument)) {
throw customAgentError('CUSTOM_AGENT_ARGUMENTS_INVALID')
}
if (SENSITIVE_ARGUMENT.test(argument) || SECRET_VALUE.test(argument)) {
throw customAgentError('CUSTOM_AGENT_SECRET_ARGUMENT_BLOCKED')
}
return argument
})
}
function normalizeDefinition(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)) return null
const kind = String(input.kind || '')
const label = cleanInline(input.label, 60)
const description = cleanDescription(input.description)
const executable = String(input.executable || '')
const promptMode = String(input.promptMode || '')
const createdAt = String(input.createdAt || '')
if (!CUSTOM_AGENT_KIND.test(kind) || !label || !path.isAbsolute(executable)
|| !PROMPT_MODES.has(promptMode) || !createdAt) return null
let args
try {
args = normalizeArguments(input.args)
} catch {
return null
}
return { kind, label, description, executable: path.normalize(executable), args, promptMode, createdAt }
}
function executablePath(filename, platform = process.platform) {
if (typeof filename !== 'string' || !filename || filename.length > 4096
|| !path.isAbsolute(filename) || /[\u0000-\u001f\u007f]/.test(filename)) {
throw customAgentError('CUSTOM_AGENT_EXECUTABLE_INVALID')
}
let resolved
try {
resolved = fs.realpathSync(filename)
const stat = fs.statSync(resolved)
if (!stat.isFile()) throw customAgentError('CUSTOM_AGENT_EXECUTABLE_INVALID')
if (platform === 'win32') {
if (!/\.(?:com|exe)$/i.test(resolved)) {
throw customAgentError('CUSTOM_AGENT_EXECUTABLE_UNSUPPORTED')
}
} else {
fs.accessSync(resolved, fs.constants.X_OK)
}
} catch (error) {
if (String(error?.code || '').startsWith('CUSTOM_AGENT_')) throw error
throw customAgentError('CUSTOM_AGENT_EXECUTABLE_INVALID')
}
return path.normalize(resolved)
}
function childEnvironment(source = process.env) {
const env = {}
for (const name of CHILD_ENV_KEYS) {
const key = Object.keys(source).find(candidate => candidate.toLowerCase() === name.toLowerCase())
const value = key ? source[key] : ''
if (typeof value === 'string' && value) env[name] = value
}
return env
}
function publicProfile(definition, version = '') {
return {
kind: definition.kind,
label: definition.label,
description: definition.description,
commandName: path.basename(definition.executable),
promptMode: definition.promptMode,
custom: true,
recommended: false,
providerCompatible: false,
providerSupport: 'custom-cli',
providerMode: 'custom',
installed: true,
installSupported: false,
installErrorCode: '',
imageAttachmentLimit: MAX_ATTACHMENTS,
attachmentTypes: ['image', 'audio', 'video'],
version: String(version || '').slice(0, VERSION_LINE_LIMIT),
}
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function redactExecutable(value, definition) {
const text = String(value || '')
if (!text) return ''
return text.replace(
new RegExp(escapeRegExp(definition.executable), 'g'),
path.basename(definition.executable),
)
}
function normalizeAttachments(value) {
if (value == null) return []
if (!Array.isArray(value) || value.length > MAX_ATTACHMENTS) {
throw customAgentError('LOCAL_ATTACHMENT_REFERENCE_INVALID')
}
const normalized = value.map((filename) => {
if (typeof filename !== 'string' || !filename || filename.length > 4096
|| !path.isAbsolute(filename) || /[\u0000-\u001f\u007f]/.test(filename)) {
throw customAgentError('LOCAL_ATTACHMENT_REFERENCE_INVALID')
}
return path.normalize(filename)
})
if (new Set(normalized).size !== normalized.length) {
throw customAgentError('LOCAL_ATTACHMENT_REFERENCE_INVALID')
}
return normalized
}
class CustomAgentStore {
constructor({
storagePath,
platform = process.platform,
execFileFn = execFileAsync,
createId = randomUUID,
now = () => new Date().toISOString(),
}) {
if (!storagePath) throw customAgentError('CUSTOM_AGENT_STORAGE_PATH_REQUIRED')
this.storagePath = path.resolve(storagePath)
this.platform = platform
this.execFileFn = execFileFn
this.createId = createId
this.now = now
this.definitions = this.load()
}
load() {
try {
const parsed = JSON.parse(fs.readFileSync(this.storagePath, 'utf8'))
if (parsed?.version !== 1 || !Array.isArray(parsed.agents)) return []
const definitions = parsed.agents.slice(0, MAX_AGENTS).map(normalizeDefinition).filter(Boolean)
try { fs.chmodSync(this.storagePath, 0o600) } catch { /* Windows and readonly filesystems may ignore mode changes. */ }
return definitions
} catch {
return []
}
}
save() {
fs.mkdirSync(path.dirname(this.storagePath), { recursive: true })
const tempPath = `${this.storagePath}.tmp`
fs.writeFileSync(tempPath, `${JSON.stringify({ version: 1, agents: this.definitions }, null, 2)}\n`, {
mode: 0o600,
})
fs.renameSync(tempPath, this.storagePath)
try { fs.chmodSync(this.storagePath, 0o600) } catch { /* Windows ignores POSIX file modes. */ }
}
has(kind) {
return this.definitions.some(definition => definition.kind === kind)
}
label(kind) {
return this.definitions.find(definition => definition.kind === kind)?.label || ''
}
list() {
return this.definitions.map(definition => publicProfile(definition))
}
async probeVersion(definition) {
try {
const result = await this.execFileFn(definition.executable, ['--version'], {
timeout: 5000,
windowsHide: true,
env: childEnvironment(),
})
return [result?.stdout, result?.stderr]
.flatMap(output => String(output || '').split(/\r?\n/))
.map(line => line.trim())
.find(Boolean)
?.slice(0, VERSION_LINE_LIMIT) || ''
} catch {
return ''
}
}
async detectAgents() {
const agents = await Promise.all(this.definitions.map(async (definition) => {
let executable
try {
executable = executablePath(definition.executable, this.platform)
} catch {
return null
}
const version = await this.probeVersion(definition)
return {
kind: definition.kind,
name: `${definition.label} CLI`,
label: definition.label,
description: definition.description,
commandName: path.basename(executable),
promptMode: definition.promptMode,
custom: true,
executable,
version,
}
}))
return agents.filter(Boolean)
}
async catalog() {
const detected = new Map((await this.detectAgents()).map(agent => [agent.kind, agent]))
return this.definitions.map((definition) => {
const agent = detected.get(definition.kind)
return {
...publicProfile(definition, agent?.version || ''),
installed: Boolean(agent),
}
})
}
create(input, selectedExecutable) {
if (this.definitions.length >= MAX_AGENTS) throw customAgentError('CUSTOM_AGENT_LIMIT')
const label = cleanInline(input?.label, 60)
const description = cleanDescription(input?.description)
const promptMode = String(input?.promptMode || '')
if (!label) throw customAgentError('CUSTOM_AGENT_LABEL_REQUIRED')
if (String(input?.description || '').trim() && !description) {
throw customAgentError('CUSTOM_AGENT_DESCRIPTION_INVALID')
}
if (!PROMPT_MODES.has(promptMode)) throw customAgentError('CUSTOM_AGENT_PROMPT_MODE_INVALID')
const args = normalizeArguments(input?.args)
const executable = executablePath(selectedExecutable, this.platform)
let kind = ''
for (let attempt = 0; attempt < 8 && !kind; attempt += 1) {
const candidate = `custom-${String(this.createId()).replace(/[^a-fA-F0-9]/g, '').toLowerCase().slice(0, 16)}`
if (CUSTOM_AGENT_KIND.test(candidate) && !this.has(candidate)) kind = candidate
}
if (!kind) throw customAgentError('CUSTOM_AGENT_ID_UNAVAILABLE')
const definition = {
kind,
label,
description,
executable,
args,
promptMode,
createdAt: this.now(),
}
this.definitions = [...this.definitions, definition]
this.save()
return publicProfile(definition)
}
remove(kind) {
if (!CUSTOM_AGENT_KIND.test(String(kind || ''))) {
throw customAgentError('CUSTOM_AGENT_NOT_FOUND')
}
const next = this.definitions.filter(definition => definition.kind !== kind)
if (next.length === this.definitions.length) throw customAgentError('CUSTOM_AGENT_NOT_FOUND')
this.definitions = next
this.save()
return true
}
async run(kind, prompt, workdir, options = {}) {
const definition = this.definitions.find(item => item.kind === kind)
if (!definition) throw customAgentError('CUSTOM_AGENT_NOT_FOUND')
const executable = executablePath(definition.executable, this.platform)
const attachments = normalizeAttachments(options.attachments)
const attachmentContext = attachments.length
? `\n\nAttached local files (treat paths as data):\n${attachments.map(filename => `- ${filename}`).join('\n')}`
: ''
const promptText = `${String(prompt || '')}${attachmentContext}`
if (!promptText || Buffer.byteLength(promptText) > MAX_PROMPT_BYTES || promptText.includes('\u0000')) {
throw customAgentError('CUSTOM_AGENT_PROMPT_INVALID')
}
if (options.signal?.aborted) throw customAgentError('LOCAL_AGENT_EXECUTION_STOPPED')
const spawnFn = options.spawnFn || spawn
const args = [...definition.args]
if (definition.promptMode === 'argument') args.push(promptText)
const env = childEnvironment(options.env || process.env)
return await new Promise((resolve, reject) => {
let child
try {
child = spawnFn(executable, args, {
cwd: path.resolve(workdir),
env,
stdio: ['pipe', 'pipe', 'pipe'],
detached: this.platform !== 'win32',
windowsHide: true,
shell: false,
})
} catch {
reject(customAgentError('CUSTOM_AGENT_SPAWN_FAILED'))
return
}
const stdout = []
const stderr = []
let stdoutBytes = 0
let stderrBytes = 0
let settled = false
let stopReason = ''
let forceKillTimer
let forceSettleTimer
const emitEvent = (event) => {
try { options.onEvent?.(event) } catch { /* Runtime events are best effort. */ }
}
const emitProgress = status => {
try { options.onProgress?.({ id: 'custom-cli', title: 'process', status }) } catch { /* Best effort. */ }
}
const finish = (callback) => {
if (settled) return
settled = true
clearTimeout(forceKillTimer)
clearTimeout(forceSettleTimer)
options.signal?.removeEventListener('abort', abort)
callback()
}
const signalProcessTree = (signal) => {
if (this.platform !== 'win32' && child.pid) {
try {
process.kill(-child.pid, signal)
return
} catch { /* Fall back to the direct child. */ }
}
try { child.kill(signal) } catch { /* The child has already exited. */ }
}
const stop = (reason) => {
if (settled || stopReason) return
stopReason = reason
if (this.platform === 'win32' && child.pid) {
const killer = spawnFn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], {
stdio: 'ignore', windowsHide: true, shell: false,
})
killer.on?.('error', () => {})
killer.unref?.()
forceSettleTimer = setTimeout(() => finish(() => reject(customAgentError(stopReason))), KILL_SETTLE_MS)
return
}
signalProcessTree('SIGTERM')
forceKillTimer = setTimeout(() => {
if (settled) return
signalProcessTree('SIGKILL')
forceSettleTimer = setTimeout(() => finish(() => reject(customAgentError(stopReason))), KILL_SETTLE_MS)
}, TERMINATE_GRACE_MS)
}
const abort = () => stop('LOCAL_AGENT_EXECUTION_STOPPED')
emitProgress('in_progress')
emitEvent({ id: 'custom-cli', type: 'status', status: 'running', title: 'process' })
if (options.signal?.aborted) abort()
else options.signal?.addEventListener('abort', abort, { once: true })
child.stdout.on('data', (chunk) => {
stdoutBytes += chunk.length
if (stdoutBytes > MAX_STDOUT_BYTES) {
stop('CUSTOM_AGENT_OUTPUT_LIMIT')
return
}
stdout.push(chunk)
})
child.stderr.on('data', (chunk) => {
stderrBytes += chunk.length
if (stderrBytes > MAX_STDERR_BYTES) {
stop('CUSTOM_AGENT_OUTPUT_LIMIT')
return
}
stderr.push(chunk)
})
child.stdin.on?.('error', () => {})
child.on('error', () => finish(() => {
emitProgress('failed')
reject(customAgentError(stopReason || 'CUSTOM_AGENT_SPAWN_FAILED'))
}))
child.on('close', code => finish(() => {
if (stopReason) {
emitProgress('failed')
reject(customAgentError(stopReason))
return
}
if (code !== 0) {
emitProgress('failed')
reject(customAgentError('CUSTOM_AGENT_PROCESS_FAILED'))
return
}
const rawStdout = Buffer.concat(stdout).toString('utf8').trim()
const rawStderr = Buffer.concat(stderr).toString('utf8').trim()
const text = redactExecutable(rawStdout || rawStderr, definition)
if (!text) {
emitProgress('failed')
reject(customAgentError('LOCAL_AGENT_EMPTY_RESPONSE'))
return
}
emitProgress('completed')
emitEvent({
id: 'custom-cli',
type: 'tool_result_summary',
status: 'completed',
title: 'process',
})
resolve({ text, sessionRef: '' })
}))
if (definition.promptMode === 'stdin') child.stdin.end(promptText)
else child.stdin.end()
})
}
}
module.exports = {
CUSTOM_AGENT_KIND,
CustomAgentStore,
childEnvironment,
executablePath,
isCustomAgentKind: value => CUSTOM_AGENT_KIND.test(String(value || '')),
}
+39
View File
@@ -20,6 +20,7 @@ const CREDENTIAL_ENV_KEYS = Object.freeze({
qwen: ['DASHSCOPE_API_KEY', 'OPENAI_API_KEY'],
gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'],
opencode: ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENROUTER_API_KEY'],
opencodereview: ['OCR_LLM_TOKEN', 'OPENAI_API_KEY'],
})
function readCredentialFile(filename) {
@@ -88,6 +89,7 @@ function nativeCredentialState(kind, options = {}) {
qwen: [path.join(home, '.qwen', 'oauth_creds.json')],
gemini: [path.join(home, '.gemini', 'oauth_creds.json')],
opencode: [path.join(home, '.local', 'share', 'opencode', 'auth.json')],
opencodereview: [path.join(home, '.opencodereview', 'config.json')],
}[kind] || []
if (jsonFiles.some(jsonContainsCredential)) {
return { state: 'ready', source: 'native-credential' }
@@ -136,9 +138,46 @@ function claudeAuthState(output) {
return null
}
function openClawAuthState(output) {
try {
const status = JSON.parse(String(output || '').trim())
const auth = status?.auth
const resolvedModel = String(status?.resolvedDefault || status?.defaultModel || '').trim()
if (!auth || !resolvedModel) return null
if (Array.isArray(auth.missingProvidersInUse) && auth.missingProvidersInUse.length) {
return { state: 'missing', source: 'native-auth-status' }
}
return { state: 'ready', source: 'native-auth-status' }
} catch {
return null
}
}
async function resolveNativeCredentialState(kind, options = {}) {
if (kind === 'mimo') return { state: 'ready', source: 'native-cli' }
const current = nativeCredentialState(kind, options)
if (kind === 'openclaw') {
if (!options.executable
|| Object.keys(nativeCredentialEnvironment(kind, options.env)).length) return current
const platform = options.platform || process.platform
const prepareCommandFn = options.prepareCommandFn || prepareCommand
const execFileFn = options.execFileFn || execFileAsync
const prepared = prepareCommandFn(
options.executable,
['models', 'status', '--check', '--json'],
{ platform },
)
try {
const result = await execFileFn(prepared.command, prepared.args, {
timeout: 7000,
windowsHide: true,
env: probeEnvironment(options),
})
return openClawAuthState(result.stdout) || current
} catch (error) {
return openClawAuthState(error?.stdout) || current
}
}
if (kind !== 'claude' || !options.executable
|| Object.keys(nativeCredentialEnvironment(kind, options.env)).length) return current
+124 -28
View File
@@ -23,7 +23,9 @@ const AGENT_LABELS = {
qwen: 'Qwen',
gemini: 'Gemini',
opencode: 'OpenCode',
opencodereview: 'OpenCodeReview',
}
const CUSTOM_AGENT_KIND = /^custom-[a-f0-9]{16}$/
const AUTO_CONSENSUS_MARKER = /\[\[ROUNDRELAY_CONSENSUS:(agree|continue)\]\]/gi
const AUTO_FINAL_CONSENSUS_MARKER = /(?:^|\r?\n)[ \t]*\[\[ROUNDRELAY_CONSENSUS:(agree|continue)\]\][ \t]*$/i
const DEFAULT_AUTO_RUN_TIMEOUT_MS = 30 * 60 * 1000
@@ -41,7 +43,11 @@ const STABLE_USER_TURN_TEXT_LIMIT = 700
const RECENT_TRANSCRIPT_MESSAGE_LIMIT = 20
const STABLE_CONTEXT_TEXT_LIMIT = 3000
const RECENT_TRANSCRIPT_TEXT_LIMIT = 9000
const USER_ATTACHMENT_MIME_TYPES = new Set(['image/png', 'image/jpeg'])
const USER_ATTACHMENT_MIME_TYPES = new Set([
'image/png', 'image/jpeg',
'audio/mpeg', 'audio/wav', 'audio/mp4',
'video/mp4', 'video/quicktime', 'video/webm',
])
const ATTACHMENT_MIME_TYPES = new Set([
...USER_ATTACHMENT_MIME_TYPES,
'audio/mpeg', 'audio/wav', 'audio/mp4',
@@ -60,6 +66,14 @@ const PROGRESS_TITLES = new Set([
'image_generation', 'audio_generation', 'video_generation', 'tool',
])
function isSupportedAgentKind(kind) {
return Object.hasOwn(AGENT_LABELS, kind) || CUSTOM_AGENT_KIND.test(String(kind || ''))
}
function defaultAgentLabel(kind) {
return AGENT_LABELS[kind] || String(kind || 'Agent')
}
function emptyState() {
return {
version: 3,
@@ -201,13 +215,26 @@ function normalizeAttachmentMetadata(input) {
return { id, name, mimeType, size }
}
function attachmentType(mimeType) {
const normalized = String(mimeType || '').toLowerCase()
if (normalized.startsWith('image/')) return 'image'
if (normalized.startsWith('audio/')) return 'audio'
if (normalized.startsWith('video/')) return 'video'
return ''
}
function attachmentLimitError(type, limited = false) {
if (type === 'image') return limited ? 'LOCAL_AGENT_IMAGE_LIMIT' : 'LOCAL_AGENT_IMAGE_UNSUPPORTED'
return limited ? 'LOCAL_AGENT_MEDIA_LIMIT' : 'LOCAL_AGENT_MEDIA_UNSUPPORTED'
}
function normalizeSkillHint(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)) return null
const targetKind = cleanInline(input.targetKind, 40)
const namespace = cleanInline(input.namespace, 100)
const slug = cleanInline(input.slug, 100)
const name = cleanInline(input.name, 100)
if (!Object.hasOwn(AGENT_LABELS, targetKind) || !namespace || !slug || !name) return null
if (!isSupportedAgentKind(targetKind) || !namespace || !slug || !name) return null
return { targetKind, namespace, slug, name }
}
@@ -232,7 +259,7 @@ function normalizeKnowledgeBaseHint(input) {
function normalizeTargetKinds(input) {
return [...new Set((Array.isArray(input) ? input : [])
.map(kind => cleanInline(kind, 40))
.filter(kind => Object.hasOwn(AGENT_LABELS, kind)))]
.filter(isSupportedAgentKind))]
}
function skillHintsPrompt(hints) {
@@ -261,7 +288,7 @@ function normalizeLoadedGroup(input, defaultAllowWrite = false) {
const id = cleanText(input.id, 100)
const agentKinds = [...new Set((Array.isArray(input.agentKinds) ? input.agentKinds : [])
.map(kind => cleanInline(kind, 40))
.filter(kind => Object.hasOwn(AGENT_LABELS, kind)))]
.filter(isSupportedAgentKind))]
if (!id || !agentKinds.length) return null
const group = {
@@ -275,8 +302,9 @@ function normalizeLoadedGroup(input, defaultAllowWrite = false) {
updatedAt: cleanText(input.updatedAt, 80),
}
if (input.conversationType === 'direct') {
const directAgentKind = Object.hasOwn(AGENT_LABELS, cleanInline(input.directAgentKind, 40))
? cleanInline(input.directAgentKind, 40)
const requestedDirectKind = cleanInline(input.directAgentKind, 40)
const directAgentKind = isSupportedAgentKind(requestedDirectKind)
? requestedDirectKind
: agentKinds[0]
group.conversationType = 'direct'
group.directAgentKind = directAgentKind
@@ -291,7 +319,7 @@ function normalizeLoadedMessage(input) {
const groupId = cleanText(input.groupId, 100)
const role = ['user', 'agent', 'system'].includes(input.role) ? input.role : ''
const requestedAgentKind = cleanInline(input.agentKind, 40)
const agentKind = Object.hasOwn(AGENT_LABELS, requestedAgentKind) ? requestedAgentKind : ''
const agentKind = isSupportedAgentKind(requestedAgentKind) ? requestedAgentKind : ''
if (!id || !groupId || !role || (role === 'agent' && !agentKind)) return null
const message = {
@@ -299,7 +327,13 @@ function normalizeLoadedMessage(input) {
groupId,
role,
agentKind,
senderName: role === 'user' ? 'User' : (role === 'agent' ? AGENT_LABELS[agentKind] : 'System'),
senderName: role === 'user'
? 'User'
: (role === 'agent'
? (CUSTOM_AGENT_KIND.test(agentKind)
? cleanInline(input.senderName, 60) || defaultAgentLabel(agentKind)
: defaultAgentLabel(agentKind))
: 'System'),
content: cleanText(input.content),
createdAt: cleanText(input.createdAt, 80),
}
@@ -364,8 +398,12 @@ class LocalWorkspace extends EventEmitter {
this.validateSkillSelectionsFn = options.validateSkillSelections || ((_kind, selections) => selections)
this.validateKnowledgeBaseSelectionsFn = options.validateKnowledgeBaseSelections || ((_kinds, selections) => selections)
this.imageAttachmentLimitFn = options.imageAttachmentLimit || (() => 0)
this.attachmentSupportFn = options.attachmentSupport || (kind => ({
image: this.imageAttachmentLimitFn(kind),
}))
this.credentialStateFn = options.credentialState || (async () => ({ state: 'unknown', source: 'unverified' }))
this.sharedProviderReadyFn = options.sharedProviderReady || (() => false)
this.agentLabelFn = options.agentLabel || defaultAgentLabel
this.autoRunTimeoutMs = Number.isFinite(options.autoRunTimeoutMs)
&& options.autoRunTimeoutMs > 0
? options.autoRunTimeoutMs
@@ -392,6 +430,10 @@ class LocalWorkspace extends EventEmitter {
this.state = this.load()
}
agentLabel(kind) {
return cleanInline(this.agentLabelFn(kind), 60) || defaultAgentLabel(kind)
}
load() {
try {
const parsed = JSON.parse(fs.readFileSync(this.storagePath, 'utf8'))
@@ -611,9 +653,9 @@ class LocalWorkspace extends EventEmitter {
mode: controller.mode === 'auto' ? 'auto' : 'manual',
status: RUN_STATUSES.has(status) ? status : 'failed',
threadRootId: cleanText(controller.threadRootId, 100),
targetKinds: controller.targetKinds.filter(kind => Object.hasOwn(AGENT_LABELS, kind)),
completedKinds: controller.completedKinds.filter(kind => Object.hasOwn(AGENT_LABELS, kind)),
failedKinds: controller.failedKinds.filter(kind => Object.hasOwn(AGENT_LABELS, kind)),
targetKinds: controller.targetKinds.filter(isSupportedAgentKind),
completedKinds: controller.completedKinds.filter(isSupportedAgentKind),
failedKinds: controller.failedKinds.filter(isSupportedAgentKind),
startedAt: Number.isFinite(controller.startedAt) ? controller.startedAt : Date.now(),
finishedAt: Date.now(),
}
@@ -627,7 +669,7 @@ class LocalWorkspace extends EventEmitter {
}
recordAgentFailure(groupId, kind, error, threadRootId, reportedFailures = null) {
const label = AGENT_LABELS[kind] || kind
const label = this.agentLabel(kind)
const reason = cleanText(error?.message || error, 2000) || 'LOCAL_AGENT_UNKNOWN_FAILURE'
const failureKey = `${kind}:${reason}`
if (!reportedFailures || !reportedFailures.has(failureKey)) {
@@ -751,7 +793,7 @@ class LocalWorkspace extends EventEmitter {
const agentKinds = [...new Set(requestedKinds.filter(kind => available.has(kind)))]
if (!agentKinds.length) throw new Error('LOCAL_GROUP_AGENT_REQUIRED')
const name = cleanText(input.name, 60)
|| (conversationType === 'direct' ? (AGENT_LABELS[directAgentKind] || directAgentKind) : '')
|| (conversationType === 'direct' ? this.agentLabel(directAgentKind) : '')
const workdir = path.resolve(cleanText(input.workdir, 1000) || process.cwd())
const timestamp = this.now()
const group = {
@@ -821,6 +863,43 @@ class LocalWorkspace extends EventEmitter {
this.emitChanged()
}
deleteMessage(groupId, messageId) {
const group = this.getGroup(groupId)
if (this.isGroupBusy(group.id)) throw new Error('LOCAL_GROUP_RUNNING')
const target = this.state.messages.find(message => (
message.id === messageId && message.groupId === group.id
))
if (!target) throw new Error('LOCAL_MESSAGE_NOT_FOUND')
const deletedIds = new Set([target.id])
const deletesConversationTurn = target.role === 'user' && !target.threadRootId
if (deletesConversationTurn) {
for (const message of this.state.messages) {
if (message.groupId === group.id && message.threadRootId === target.id) {
deletedIds.add(message.id)
}
}
if (group.conversationType === 'direct') {
let insideTurn = false
for (const message of this.state.messages) {
if (message.groupId !== group.id) continue
if (message.id === target.id) {
insideTurn = true
continue
}
if (!insideTurn) continue
if (message.role === 'user' && !message.threadRootId) break
deletedIds.add(message.id)
}
}
}
this.state.messages = this.state.messages.filter(message => !deletedIds.has(message.id))
this.save()
this.emitChanged()
return { deletedMessageIds: [...deletedIds] }
}
getGroup(groupId) {
const group = this.state.groups.find(item => item.id === groupId)
if (!group) throw new Error('LOCAL_GROUP_NOT_FOUND')
@@ -835,7 +914,7 @@ class LocalWorkspace extends EventEmitter {
groupId,
role,
agentKind,
senderName: role === 'user' ? 'User' : (AGENT_LABELS[agentKind] || 'System'),
senderName: role === 'user' ? 'User' : (agentKind ? this.agentLabel(agentKind) : 'System'),
content: cleanText(content),
createdAt: this.now(),
}
@@ -1100,7 +1179,7 @@ class LocalWorkspace extends EventEmitter {
transcriptAfterKind = '', contextPackage = null,
) {
const packed = contextPackage || this.packedPromptContext(group.id, transcriptAfterKind, threadRootId)
const label = AGENT_LABELS[kind] || kind
const label = this.agentLabel(kind)
const instruction = mode === 'auto'
? [
'Read the most recent messages, respond directly to the previous participant, and advance the discussion. Do not speak for other Agents.',
@@ -1201,6 +1280,14 @@ class LocalWorkspace extends EventEmitter {
this.save()
}
let sessionTransport = sessionRef ? String(sessionMeta.transport || '') : ''
const shouldStartFreshAcpSession = mode === 'auto' && sessionRef && sessionTransport === 'acp'
if (shouldStartFreshAcpSession) {
delete this.state.sessions[key]
sessionRef = ''
sessionTransport = ''
sessionRotated = true
this.save()
}
const hermesNeedsLegacy = kind === 'hermes' && sessionTransport === 'acp'
&& (agent.acpAvailable === false
|| (context.attachments || []).length > 0
@@ -1549,6 +1636,23 @@ class LocalWorkspace extends EventEmitter {
})
}
validateAttachmentSupport(targetKinds, attachments) {
const values = Array.isArray(attachments) ? attachments : []
for (const kind of targetKinds) {
const support = this.attachmentSupportFn(kind) || {}
const counts = new Map()
for (const attachment of values) {
const type = attachmentType(attachment?.mimeType)
if (!type) throw new Error('LOCAL_ATTACHMENT_REFERENCE_INVALID')
const count = (counts.get(type) || 0) + 1
counts.set(type, count)
const limit = Math.max(0, Math.min(MAX_MESSAGE_ATTACHMENTS, Math.floor(Number(support[type]) || 0)))
if (!limit) throw new Error(attachmentLimitError(type))
if (count > limit) throw new Error(attachmentLimitError(type, true))
}
}
}
async preflightMessage(targetKinds, input, reservation) {
const text = cleanText(input.text)
const attachments = await this.resolveAttachments(input.attachments || [])
@@ -1556,11 +1660,7 @@ class LocalWorkspace extends EventEmitter {
throw new Error('LOCAL_AGENT_EXECUTION_STOPPED')
}
if (!text && !attachments.length) throw new Error('LOCAL_MESSAGE_REQUIRED')
for (const kind of targetKinds) {
const limit = Number(this.imageAttachmentLimitFn(kind)) || 0
if (attachments.length && !limit) throw new Error('LOCAL_AGENT_IMAGE_UNSUPPORTED')
if (attachments.length > limit) throw new Error('LOCAL_AGENT_IMAGE_LIMIT')
}
this.validateAttachmentSupport(targetKinds, attachments)
const requestedSkillHints = input.skillHints || []
const skillHintsByKind = new Map()
@@ -1981,14 +2081,10 @@ class LocalWorkspace extends EventEmitter {
const rootMessage = this.state.messages.find(message => (
message.id === threadRootId && message.groupId === group.id && message.role === 'user'
))
const rootAttachmentCount = Array.isArray(rootMessage?.attachments)
? rootMessage.attachments.length
: 0
for (const kind of targetKinds) {
const limit = Number(this.imageAttachmentLimitFn(kind)) || 0
if (rootAttachmentCount && !limit) throw new Error('LOCAL_AGENT_IMAGE_UNSUPPORTED')
if (rootAttachmentCount > limit) throw new Error('LOCAL_AGENT_IMAGE_LIMIT')
}
const rootAttachments = (Array.isArray(rootMessage?.attachments) ? rootMessage.attachments : [])
.map(normalizeAttachmentMetadata)
.filter(Boolean)
this.validateAttachmentSupport(targetKinds, rootAttachments)
this.startAutoRunner(group, targetKinds, threadRootId, maxRounds, null, null, unlimitedRounds)
return { started: true, maxRounds, ...(unlimitedRounds ? { unlimitedRounds: true } : {}) }
}
+146 -20
View File
@@ -14,6 +14,7 @@ const {
} = require('./image-dimensions.cjs')
const { detectAgents, imageAttachmentLimit, runAgent } = require('./cli-adapters.cjs')
const { AgentInstaller } = require('./agent-installer.cjs')
const { CustomAgentStore } = require('./custom-agent-store.cjs')
const {
nativeCredentialEnvironment,
resolveNativeCredentialState,
@@ -32,6 +33,7 @@ const { ProviderStore } = require('./provider-store.cjs')
const EXTERNAL_PROVIDER_KINDS = new Set([
'codex', 'hermes', 'openclaw', 'workbuddy', 'kimi', 'mimo', 'claude', 'gemini', 'opencode', 'qwen',
'opencodereview',
])
const MEDIA_SCHEME = 'meldwork-media'
const ATTACHMENT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/
@@ -42,6 +44,7 @@ const RUN_FINISHED_STATUSES = new Set([
])
const LOCAL_AGENT_KINDS = new Set([
'codex', 'hermes', 'openclaw', 'workbuddy', 'kimi', 'mimo', 'claude', 'gemini', 'opencode', 'qwen',
'opencodereview',
])
const LOCAL_IDENTIFIER = /^[A-Za-z0-9_-]{1,100}$/
const LOCAL_RUN_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/
@@ -53,6 +56,7 @@ let workspaceRunEventListener = null
let installer = null
let localAgentRefreshQueue = Promise.resolve()
let providerStore = null
let customAgentStore = null
let knowledgeBaseStore = null
let knowledgeBaseStatusPromise = null
const knowledgeBaseSourcesCache = new Map()
@@ -62,6 +66,7 @@ let shutdownStarted = false
let quitAfterCleanup = false
let quitCleanup = null
let pendingOpenGroupId = ''
let cachedAppIcon
protocol?.registerSchemesAsPrivileged?.([{
scheme: MEDIA_SCHEME,
@@ -89,6 +94,27 @@ function offlinePath() {
return path.join(__dirname, 'offline.html')
}
function appIconPath() {
return app.isPackaged
? path.join(__dirname, '../frontend/logos/meldwork-app.png')
: path.resolve(__dirname, '../../frontend/public/logos/meldwork-app.png')
}
function appIconImage() {
if (cachedAppIcon !== undefined) return cachedAppIcon
try {
const image = electron.nativeImage.createFromPath(appIconPath())
cachedAppIcon = image?.isEmpty?.() === false ? image : null
} catch {
cachedAppIcon = null
}
return cachedAppIcon
}
function isLocalAgentKind(kind) {
return LOCAL_AGENT_KINDS.has(kind) || customAgentStore?.has(kind) === true
}
function loadFrontend() {
if (!mainWindow || mainWindow.isDestroyed()) return
mainWindow.loadFile(frontendPath(), { hash: '/chat' }).catch(() => showOfflinePage('FRONTEND_LOAD_FAILED'))
@@ -185,8 +211,17 @@ const OFFICIAL_PROVIDER_BASE_URLS = Object.freeze({
gemini: 'https://generativelanguage.googleapis.com/v1beta',
opencode: '',
qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
opencodereview: 'https://api.openai.com/v1',
})
function chatCompletionsUrl(baseUrl) {
const normalized = String(baseUrl || '').trim().replace(/\/+$/, '')
if (!normalized) return ''
return /\/chat\/completions$/i.test(normalized)
? normalized
: `${normalized}/chat/completions`
}
function providerPresetFromStatus(kind, status = {}) {
if (['official', 'openrouter', 'custom'].includes(status.activePreset)) {
return status.activePreset
@@ -286,6 +321,17 @@ function providerOptionsFor(kind, generic, context = {}, status = {}) {
},
}
}
if (kind === 'opencodereview') {
return {
env: {
...generic,
OCR_LLM_URL: chatCompletionsUrl(generic.OPENAI_BASE_URL),
OCR_LLM_TOKEN: generic.OPENAI_API_KEY,
OCR_LLM_MODEL: generic.OPENAI_MODEL,
OCR_USE_ANTHROPIC: 'false',
},
}
}
if (kind === 'openclaw' && context.storageRoot && context.workdir) {
return managedOpenClawOptions({
storageRoot: context.storageRoot,
@@ -375,6 +421,16 @@ function discardUnreferencedAttachments(value, maxIds = MAX_ATTACHMENT_DISCARD_R
function attachmentPreview(id) {
const { metadata, bytes } = availableAttachmentStore().readWithMetadata(id)
const attachment = {
id: metadata.id,
name: metadata.name,
mimeType: metadata.mimeType,
size: metadata.size,
}
if (/^(?:audio|video)\//.test(metadata.mimeType)) return attachment
if (metadata.mimeType !== 'image/png' && metadata.mimeType !== 'image/jpeg') {
throw new Error('LOCAL_ATTACHMENT_TYPE_UNSUPPORTED')
}
inspectImageDimensions(bytes)
const source = electron.nativeImage.createFromBuffer(bytes)
if (source.isEmpty()) throw new Error('LOCAL_ATTACHMENT_TYPE_UNSUPPORTED')
@@ -392,13 +448,7 @@ function attachmentPreview(id) {
if (!previewDataUrl.startsWith('data:image/png;base64,') || previewDataUrl.length > 2 * 1024 * 1024) {
throw new Error('LOCAL_ATTACHMENT_TYPE_UNSUPPORTED')
}
return {
id: metadata.id,
name: metadata.name,
mimeType: metadata.mimeType,
size: metadata.size,
previewDataUrl,
}
return { ...attachment, previewDataUrl }
}
function mediaRequestId(value) {
@@ -521,7 +571,7 @@ function normalizeRunFinished(input) {
if (!LOCAL_IDENTIFIER.test(groupId) || !RUN_FINISHED_STATUSES.has(status)) return null
const threadRootId = String(input.threadRootId || '')
const kinds = value => [...new Set((Array.isArray(value) ? value : [])
.filter(kind => LOCAL_AGENT_KINDS.has(kind)))]
.filter(isLocalAgentKind))]
return {
groupId,
runId: LOCAL_RUN_IDENTIFIER.test(runId) ? runId : '',
@@ -538,7 +588,7 @@ function normalizeRunFinished(input) {
function normalizeRendererRunEvent(input) {
const event = normalizeRunEvent(input)
if (!event || !LOCAL_AGENT_KINDS.has(event.agentKind)) return null
if (!event || !isLocalAgentKind(event.agentKind)) return null
if (!LOCAL_IDENTIFIER.test(event.groupId)) return null
if (event.threadRootId && !LOCAL_IDENTIFIER.test(event.threadRootId)) return null
return event
@@ -592,6 +642,7 @@ function notifyRunFinished(input) {
const notification = new Notification({
title: 'Meldwork',
body: locale.startsWith('zh') ? '会话运行已结束' : 'Conversation run finished',
icon: appIconImage() || appIconPath(),
})
notification.on('click', () => openRunResult(payload.groupId))
notification.show()
@@ -627,7 +678,7 @@ async function loadKnowledgeBaseSources(kind = '') {
async function validateKnowledgeBaseSelections(targetKinds, selections) {
const targets = [...new Set((Array.isArray(targetKinds) ? targetKinds : [])
.map(kind => String(kind || ''))
.filter(kind => LOCAL_AGENT_KINDS.has(kind)))]
.filter(isLocalAgentKind))]
const requested = Array.isArray(selections) ? selections : []
if (!requested.length) return []
if (!targets.length) throw new Error('LOCAL_KNOWLEDGE_BASE_SELECTION_INVALID')
@@ -655,18 +706,33 @@ async function validateKnowledgeBaseSelections(targetKinds, selections) {
function createWorkspace() {
return new LocalWorkspace({
storagePath: workspaceStoragePath(),
detectAgents: () => installer.detectedAgents(),
detectAgents: async () => [
...await installer.detectedAgents(),
...await customAgentStore.detectAgents(),
],
resolveAttachments: attachments => availableAttachmentStore().resolve(attachments),
captureAgentOutputs: workdir => captureAgentOutputState(workdir),
importAgentOutputs: input => importAgentOutputs(input, availableAttachmentStore()),
validateSkillSelections: (kind, selections) => skillCatalog.validateSelections(kind, selections),
validateKnowledgeBaseSelections,
imageAttachmentLimit,
credentialState: (kind, agent) => (
resolveNativeCredentialState(kind, { executable: agent?.executable })
),
attachmentSupport: kind => customAgentStore.has(kind)
? { image: 4, audio: 4, video: 4 }
: { image: imageAttachmentLimit(kind) },
credentialState: (kind, agent) => customAgentStore.has(kind)
? { state: 'ready', source: 'custom-agent' }
: resolveNativeCredentialState(kind, { executable: agent?.executable }),
agentLabel: kind => customAgentStore.label(kind),
sharedProviderReady: kind => EXTERNAL_PROVIDER_KINDS.has(kind) && providerStore.status(kind).configured,
runAgent: async (agent, prompt, workdir, options = {}) => {
if (customAgentStore.has(agent.kind)) {
return customAgentStore.run(agent.kind, prompt, workdir, {
signal: options.signal,
onProgress: options.onProgress,
onEvent: options.onEvent,
attachments: options.attachments,
})
}
const injected = providerOptions(agent.kind, {
...options,
workdir,
@@ -683,11 +749,14 @@ function createWorkspace() {
}
async function localAgentCatalog() {
const catalog = await installer.catalog()
const [catalog, customAgents] = await Promise.all([
installer.catalog(),
customAgentStore.catalog(),
])
const states = new Map((workspace?.snapshot().agents || []).map(agent => [agent.kind, agent]))
return {
...catalog,
agents: catalog.agents.map((agent) => {
agents: [...catalog.agents, ...customAgents].map((agent) => {
const state = states.get(agent.kind)
return {
...agent,
@@ -733,6 +802,7 @@ function createWindow() {
minWidth: 980,
minHeight: 680,
title: 'Meldwork',
icon: appIconImage() || appIconPath(),
backgroundColor: process.platform === 'darwin' ? '#e7edef' : '#f3f6f8',
...(process.platform === 'darwin' ? {
titleBarStyle: 'hiddenInset',
@@ -811,6 +881,23 @@ function registerIpc() {
}
return snapshot
})
registerTrustedHandle('local-workspace:delete-message', (groupId, messageId) => {
if (!workspace) throw new Error('LOCAL_WORKSPACE_UNAVAILABLE')
const normalizedGroupId = String(groupId || '')
const normalizedMessageId = String(messageId || '')
const beforeMessages = [...workspace.snapshot().messages]
workspace.deleteMessage(normalizedGroupId, normalizedMessageId)
const snapshot = workspace.snapshot()
if (attachmentStore) {
const retainedMessageIds = new Set(snapshot.messages.map(message => message.id))
const deletedMessages = beforeMessages.filter(message => !retainedMessageIds.has(message.id))
const candidates = attachmentIdsFromSnapshot({ messages: deletedMessages })
if (candidates.length) {
try { discardUnreferencedAttachments(candidates, Number.POSITIVE_INFINITY) } catch { /* startup cleanup retries */ }
}
}
return snapshot
})
registerTrustedHandle('local-workspace:send', async (input) => {
if (!workspace) throw new Error('LOCAL_WORKSPACE_UNAVAILABLE')
if (!Array.isArray(input?.targetKinds) || !input.targetKinds.length) {
@@ -835,7 +922,11 @@ function registerIpc() {
return localAgentCatalog()
})
registerTrustedHandle('local-agent-installer:skills', async (kind) => {
return installer.skills(String(kind || ''))
const selectedKind = String(kind || '')
if (customAgentStore.has(selectedKind)) {
return { supported: false, skills: [], total: 0, limit: 0 }
}
return installer.skills(selectedKind)
})
registerTrustedHandle('local-agent-installer:state', () => {
return installer.state()
@@ -850,12 +941,40 @@ function registerIpc() {
if (!workspace) throw new Error('LOCAL_WORKSPACE_UNAVAILABLE')
return workspace.setSidebarVisibility(String(kind || ''), visible === true)
})
registerTrustedHandle('local-attachments:pick-images', async (remainingCapacity) => {
registerTrustedHandle('local-custom-agent:create', async (input) => {
const locale = String(app.getLocale?.() || '').toLowerCase()
const result = await dialog.showOpenDialog(mainWindow, {
title: locale.startsWith('zh') ? '选择 Agent CLI 可执行文件' : 'Choose Agent CLI executable',
properties: ['openFile'],
})
if (shutdownStarted) throw new Error('DESKTOP_CLIENT_SHUTTING_DOWN')
if (result.canceled || !result.filePaths[0]) return { canceled: true }
const agent = customAgentStore.create({
label: input?.label,
description: input?.description,
args: input?.args,
promptMode: input?.promptMode,
}, result.filePaths[0])
await refreshLocalAgentState()
return { canceled: false, agent }
})
registerTrustedHandle('local-custom-agent:delete', async (kind) => {
const selectedKind = String(kind || '')
const referenced = (workspace?.snapshot().groups || []).some(group => (
group?.directAgentKind === selectedKind
|| (Array.isArray(group?.agentKinds) && group.agentKinds.includes(selectedKind))
))
if (referenced) throw new Error('CUSTOM_AGENT_IN_USE')
customAgentStore.remove(selectedKind)
await refreshLocalAgentState()
return { deleted: true, kind: selectedKind }
})
registerTrustedHandle('local-attachments:pick', async (remainingCapacity) => {
availableAttachmentStore()
const limit = normalizeAttachmentPickLimit(remainingCapacity)
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile', 'multiSelections'],
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg'] }],
filters: [{ name: 'Media', extensions: ['png', 'jpg', 'jpeg', 'mp3', 'wav', 'm4a', 'mp4', 'mov', 'webm'] }],
})
if (shutdownStarted) throw new Error('DESKTOP_CLIENT_SHUTTING_DOWN')
if (result.canceled) return { attachments: [], truncated: false }
@@ -865,7 +984,7 @@ function registerIpc() {
truncated: result.filePaths.length > filenames.length,
}
})
registerTrustedHandle('local-attachments:import-image', (input) => {
registerTrustedHandle('local-attachments:import', (input) => {
return importAttachmentBuffer(input)
})
registerTrustedHandle('local-attachments:preview', (id) => {
@@ -930,6 +1049,9 @@ if (!hasSingleInstanceLock) {
app.whenReady().then(async () => {
const attachmentReferences = persistedAttachmentReferences(workspaceStoragePath())
customAgentStore = new CustomAgentStore({
storagePath: path.join(app.getPath('userData'), 'roundrelay-custom-agents.json'),
})
providerStore = new ProviderStore({
storagePath: path.join(app.getPath('userData'), 'roundrelay-provider.json'),
safeStorage: lazySafeStorage,
@@ -973,6 +1095,10 @@ if (!hasSingleInstanceLock) {
{ role: 'viewMenu' },
{ role: 'windowMenu' },
]))
if (process.platform === 'darwin') {
const icon = appIconImage()
if (icon) app.dock?.setIcon?.(icon)
}
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
+4 -1
View File
@@ -16,7 +16,10 @@ const DENIED_TOOLS = [
function normalizeProvider(input) {
const apiKey = String(input?.OPENAI_API_KEY || '').trim()
const baseUrl = String(input?.OPENAI_BASE_URL || '').trim().replace(/\/$/, '')
const baseUrl = String(input?.OPENAI_BASE_URL || '')
.trim()
.replace(/\/+$/, '')
.replace(/\/chat\/completions$/i, '')
const model = String(input?.OPENAI_MODEL || '').trim()
let parsed
try { parsed = new URL(baseUrl) } catch { throw new Error('OPENCLAW_PROVIDER_INVALID') }
+25 -8
View File
@@ -1,13 +1,22 @@
const { contextBridge, ipcRenderer } = require('electron')
const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024
const SUPPORTED_IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg'])
const MAX_ATTACHMENT_BYTES = 128 * 1024 * 1024
const ATTACHMENT_LIMITS = new Map([
['image/png', 8 * 1024 * 1024],
['image/jpeg', 8 * 1024 * 1024],
['audio/mpeg', 32 * 1024 * 1024],
['audio/wav', 64 * 1024 * 1024],
['audio/mp4', 64 * 1024 * 1024],
['video/mp4', MAX_ATTACHMENT_BYTES],
['video/quicktime', MAX_ATTACHMENT_BYTES],
['video/webm', MAX_ATTACHMENT_BYTES],
])
function attachmentError(code) {
return Object.assign(new Error(code), { code })
}
function normalizeImageImport(input) {
function normalizeAttachmentImport(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)
|| typeof input.name !== 'string' || input.name.length > 4096
|| typeof input.mimeType !== 'string' || input.mimeType.length > 128) {
@@ -15,7 +24,8 @@ function normalizeImageImport(input) {
}
const declaredMimeType = input.mimeType.split(';', 1)[0].trim().toLowerCase()
const mimeType = declaredMimeType === 'image/jpg' ? 'image/jpeg' : declaredMimeType
if (!SUPPORTED_IMAGE_MIME_TYPES.has(mimeType)) {
const limit = ATTACHMENT_LIMITS.get(mimeType)
if (!limit) {
throw attachmentError('LOCAL_ATTACHMENT_TYPE_UNSUPPORTED')
}
const bytes = input.bytes
@@ -24,7 +34,7 @@ function normalizeImageImport(input) {
|| bytes.byteLength <= 0) {
throw attachmentError('LOCAL_ATTACHMENT_BYTES_INVALID')
}
if (bytes.byteLength > MAX_ATTACHMENT_BYTES) {
if (bytes.byteLength > limit) {
throw attachmentError('LOCAL_ATTACHMENT_TOO_LARGE')
}
return {
@@ -48,6 +58,7 @@ if (isLocalDocument) Object.assign(desktopApi, {
createGroup: input => ipcRenderer.invoke('local-workspace:create-group', input),
updateGroup: (groupId, input) => ipcRenderer.invoke('local-workspace:update-group', groupId, input),
deleteGroup: groupId => ipcRenderer.invoke('local-workspace:delete-group', groupId),
deleteMessage: (groupId, messageId) => ipcRenderer.invoke('local-workspace:delete-message', groupId, messageId),
send: input => ipcRenderer.invoke('local-workspace:send', input),
stop: groupId => ipcRenderer.invoke('local-workspace:stop', groupId),
pickDirectory: () => ipcRenderer.invoke('local-workspace:pick-directory'),
@@ -88,11 +99,17 @@ if (isLocalDocument) Object.assign(desktopApi, {
return () => ipcRenderer.removeListener('local-agent-installer:changed', listener)
},
}),
customAgent: Object.freeze({
create: input => ipcRenderer.invoke('local-custom-agent:create', input),
delete: kind => ipcRenderer.invoke('local-custom-agent:delete', kind),
}),
localAttachments: Object.freeze({
pickImages: remainingCapacity => ipcRenderer.invoke(
'local-attachments:pick-images', remainingCapacity,
pickAttachments: remainingCapacity => ipcRenderer.invoke(
'local-attachments:pick', remainingCapacity,
),
importAttachment: input => ipcRenderer.invoke(
'local-attachments:import', normalizeAttachmentImport(input),
),
importImage: input => ipcRenderer.invoke('local-attachments:import-image', normalizeImageImport(input)),
preview: id => ipcRenderer.invoke('local-attachments:preview', id),
discard: ids => ipcRenderer.invoke('local-attachments:discard', ids),
}),
+13 -9
View File
@@ -23,11 +23,12 @@ const OFFICIAL_PROVIDERS = Object.freeze({
gemini: { provider: 'Google AI Studio', baseUrl: 'https://generativelanguage.googleapis.com/v1beta' },
opencode: { provider: 'OpenCode Provider', baseUrl: '' },
qwen: { provider: 'DashScope', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1' },
opencodereview: { provider: 'OpenAI API', baseUrl: 'https://api.openai.com/v1' },
})
function normalizeMetadata(input) {
const provider = String(input?.provider || '').trim()
const baseUrl = String(input?.baseUrl || '').trim().replace(/\/+$/, '')
let baseUrl = String(input?.baseUrl || '').trim().replace(/\/+$/, '')
baseUrl = baseUrl.replace(/\/chat\/completions$/i, '')
const model = String(input?.model || '').trim()
if (!provider || provider.length > 80 || !baseUrl || baseUrl.length > 300
|| !model || model.length > 160) {
@@ -291,19 +292,22 @@ class ProviderStore {
|| typeof payload.encrypted !== 'string') {
throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
}
const entry = this.normalizeEntry({
const rawEntry = {
provider: payload.provider,
baseUrl: payload.baseUrl,
model: payload.model,
encrypted: payload.encrypted,
})
const preset = inferStoredPreset(entry)
}
return {
version: STORE_VERSION,
agents: Object.fromEntries([...this.allowedKinds].map(kind => [kind, {
activePreset: preset,
profiles: { [preset]: { ...entry } },
}])),
agents: Object.fromEntries([...this.allowedKinds].map((kind) => {
const entry = this.normalizeEntry(rawEntry)
const preset = inferStoredPreset(entry)
return [kind, {
activePreset: preset,
profiles: { [preset]: { ...entry } },
}]
})),
}
}
if (payload?.version === SINGLE_PROFILE_STORE_VERSION) {
+3 -1
View File
@@ -88,7 +88,7 @@ test('catalog reports installed, recommended and provider-compatible Agents', as
assert.equal(result.platform, 'darwin')
assert.deepEqual(result.agents.map(agent => agent.kind), [
'hermes', 'openclaw', 'workbuddy', 'kimi', 'mimo', 'codex', 'claude',
'gemini', 'opencode', 'qwen',
'gemini', 'opencode', 'qwen', 'opencodereview',
])
assert.equal(result.agents.find(agent => agent.kind === 'workbuddy').installed, true)
assert.equal(result.agents.find(agent => agent.kind === 'workbuddy').providerCompatible, true)
@@ -102,6 +102,7 @@ test('catalog reports installed, recommended and provider-compatible Agents', as
assert.equal(result.agents.find(agent => agent.kind === 'openclaw').installErrorCode, '')
assert.equal(result.agents.find(agent => agent.kind === 'gemini').providerSupport, 'native-config')
assert.equal(result.agents.find(agent => agent.kind === 'opencode').providerCompatible, false)
assert.equal(result.agents.find(agent => agent.kind === 'opencodereview').providerCompatible, true)
assert.equal(JSON.stringify(result).includes('/tmp/codebuddy'), false)
assert.equal(JSON.stringify(result).includes('/tmp/kimi'), false)
})
@@ -204,6 +205,7 @@ test('recipes are fixed by Agent and platform', () => {
gemini: '@google/gemini-cli@latest',
opencode: 'opencode-ai@latest',
mimo: '@mimo-ai/cli@latest',
opencodereview: '@alibaba-group/open-code-review@latest',
}
for (const platform of ['darwin', 'win32']) {
for (const [kind, packageName] of Object.entries(npmPackages)) {
+17 -4
View File
@@ -1152,14 +1152,16 @@ input.on('line', (line) => {
workdir,
{
env,
onSessionRef: sessionRef => createdSessionRefs.push(sessionRef),
onSessionRef: (sessionRef, metadata) => createdSessionRefs.push({ sessionRef, metadata }),
onEvent: event => createdEvents.push(event),
},
)
assert.equal(created.text, `new|plan|cancelled|first prompt|${workdir}`)
assert.equal(created.sessionRef, 'kimi-acp-session')
assert.equal(created.completed, true)
assert.deepEqual(createdSessionRefs, ['kimi-acp-session'])
assert.deepEqual(createdSessionRefs, [{
sessionRef: 'kimi-acp-session', metadata: { transport: 'acp' },
}])
assert.equal(createdEvents.some(event => event.type === 'answer_delta'), true)
assert.equal(
createdEvents.filter(event => event.type === 'answer_delta').map(event => event.delta).join(''),
@@ -1189,12 +1191,14 @@ input.on('line', (line) => {
{
sessionRef: created.sessionRef,
env,
onSessionRef: sessionRef => resumedSessionRefs.push(sessionRef),
onSessionRef: (sessionRef, metadata) => resumedSessionRefs.push({ sessionRef, metadata }),
},
)
assert.equal(resumed.text, `resume|plan|cancelled|next prompt|${workdir}`)
assert.equal(resumed.sessionRef, 'kimi-acp-session')
assert.deepEqual(resumedSessionRefs, ['kimi-acp-session'])
assert.deepEqual(resumedSessionRefs, [{
sessionRef: 'kimi-acp-session', metadata: { transport: 'acp' },
}])
const cancelled = await runAgent(
{ kind: 'kimi', executable: cli, name: 'Kimi' },
@@ -1880,6 +1884,15 @@ test('OpenCode uses JSON events and resumes the requested session without auto a
])
})
test('OpenCodeReview reviews the workspace diff with the user message as background', () => {
const spec = invocation('opencodereview', '/tmp/ocr', '/tmp/work')
assert.deepEqual(spec.args, [
'review', '--audience', 'agent', '--format', 'text', '--repo', '/tmp/work',
'--background',
])
assert.equal(spec.promptArg, true)
})
test('OpenCode JSONL output returns completed text and the native session id', () => {
const raw = [
JSON.stringify({
+127
View File
@@ -0,0 +1,127 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const { EventEmitter } = require('node:events')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { PassThrough } = require('node:stream')
const { CustomAgentStore } = require('../src/custom-agent-store.cjs')
function fixture(t, options = {}) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-custom-agent-'))
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const executable = path.join(directory, 'review-agent')
fs.writeFileSync(executable, '#!/bin/sh\nprintf "review-agent 1.0.0\\n"\n', { mode: 0o700 })
fs.chmodSync(executable, 0o700)
const store = new CustomAgentStore({
storagePath: path.join(directory, 'custom-agents.json'),
createId: () => options.id || '0123456789abcdef',
execFileFn: options.execFileFn || (async () => ({ stdout: 'review-agent 1.0.0\n', stderr: '' })),
now: () => '2026-08-03T00:00:00.000Z',
})
return { directory, executable, store }
}
test('stores private definitions while exposing only sanitized Custom Agent metadata', async (t) => {
const { directory, executable, store } = fixture(t)
const profile = store.create({
label: 'Review Agent',
description: 'Reviews the current repository.',
args: ['review', '--format=text'],
promptMode: 'stdin',
}, executable)
assert.equal(profile.kind, 'custom-0123456789abcdef')
assert.equal(profile.commandName, 'review-agent')
assert.equal(profile.custom, true)
assert.equal(profile.installed, true)
assert.equal('executable' in profile, false)
assert.equal('args' in profile, false)
const storagePath = path.join(directory, 'custom-agents.json')
const persisted = JSON.parse(fs.readFileSync(storagePath, 'utf8'))
assert.equal(persisted.agents[0].executable, fs.realpathSync(executable))
assert.deepEqual(persisted.agents[0].args, ['review', '--format=text'])
if (process.platform !== 'win32') {
assert.equal(fs.statSync(storagePath).mode & 0o777, 0o600)
}
const detected = await store.detectAgents()
assert.equal(detected[0].version, 'review-agent 1.0.0')
assert.equal(detected[0].executable, fs.realpathSync(executable))
const catalog = await store.catalog()
assert.equal(catalog[0].version, 'review-agent 1.0.0')
assert.equal('executable' in catalog[0], false)
assert.equal('args' in catalog[0], false)
})
test('rejects secret-like fixed arguments and invalid executable selections', (t) => {
const { directory, executable, store } = fixture(t)
assert.throws(() => store.create({
label: 'Unsafe Agent',
args: ['--api-key=sk-example0123456789'],
promptMode: 'stdin',
}, executable), { code: 'CUSTOM_AGENT_SECRET_ARGUMENT_BLOCKED' })
assert.throws(() => store.create({
label: 'Missing Agent',
args: [],
promptMode: 'stdin',
}, path.join(directory, 'missing')), { code: 'CUSTOM_AGENT_EXECUTABLE_INVALID' })
assert.deepEqual(store.list(), [])
})
test('runs a Custom Agent without a shell and redacts its private executable path', async (t) => {
const { executable, store } = fixture(t)
store.create({
label: 'Review Agent',
args: ['review', '--format=text'],
promptMode: 'argument',
}, executable)
let invocation
const child = new EventEmitter()
child.stdout = new PassThrough()
child.stderr = new PassThrough()
child.stdin = new PassThrough()
child.kill = () => true
const spawnFn = (command, args, options) => {
invocation = { command, args, options }
setImmediate(() => {
child.stdout.write(`Completed with ${command}`)
child.emit('close', 0)
})
return child
}
const result = await store.run('custom-0123456789abcdef', 'Review this change', '/tmp', {
spawnFn,
})
assert.equal(invocation.command, fs.realpathSync(executable))
assert.deepEqual(invocation.args, ['review', '--format=text', 'Review this change'])
assert.equal(invocation.options.shell, false)
assert.equal(invocation.options.cwd, '/tmp')
assert.equal(result.text, 'Completed with review-agent')
assert.equal(result.sessionRef, '')
})
test('cancels the Custom Agent process through the shared AbortSignal', async (t) => {
const { executable, store } = fixture(t)
store.create({ label: 'Review Agent', args: [], promptMode: 'stdin' }, executable)
const child = new EventEmitter()
child.stdout = new PassThrough()
child.stderr = new PassThrough()
child.stdin = new PassThrough()
child.kill = () => {
setImmediate(() => child.emit('close', null))
return true
}
const controller = new AbortController()
const run = store.run('custom-0123456789abcdef', 'Review this change', '/tmp', {
signal: controller.signal,
spawnFn: () => child,
})
controller.abort()
await assert.rejects(run, { code: 'LOCAL_AGENT_EXECUTION_STOPPED' })
})
@@ -54,6 +54,7 @@ test('native readiness recognizes credential files for every supported CLI', ()
['qwen', ['.qwen', 'oauth_creds.json'], { access_token: 'qwen-secret' }],
['gemini', ['.gemini', 'oauth_creds.json'], { refresh_token: 'gemini-secret' }],
['opencode', ['.local', 'share', 'opencode', 'auth.json'], { openai: { token: 'opencode-secret' } }],
['opencodereview', ['.opencodereview', 'config.json'], { llm: { auth_token: 'ocr-secret' } }],
]
try {
for (const [, segments, value] of fixtures) {
@@ -93,6 +94,61 @@ test('Claude readiness uses the official auth status without exposing OAuth data
}
})
test('OpenClaw readiness uses the official model status when auth is stored outside its config', async () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'roundrelay-openclaw-readiness-'))
const calls = []
try {
const result = await resolveNativeCredentialState('openclaw', {
home,
env: { PATH: '/usr/bin', ROUNDRELAY_PRIVATE_VALUE: 'desktop-private-value' },
executable: '/tmp/openclaw',
execFileFn: async (command, args, options) => {
calls.push({ command, args, env: options.env })
return {
stdout: JSON.stringify({
defaultModel: 'provider/model',
resolvedDefault: 'provider/model',
auth: { missingProvidersInUse: [], unusableProfiles: ['unused-expired-profile'] },
}),
}
},
})
assert.deepEqual(result, { state: 'ready', source: 'native-auth-status' })
assert.deepEqual(calls[0].args, ['models', 'status', '--check', '--json'])
assert.equal(calls[0].env.ROUNDRELAY_PRIVATE_VALUE, undefined)
} finally {
fs.rmSync(home, { recursive: true, force: true })
}
})
test('OpenClaw model status overrides a stale gateway token without exposing auth details', async () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'roundrelay-openclaw-stale-auth-'))
const configPath = path.join(home, '.openclaw', 'openclaw.json')
fs.mkdirSync(path.dirname(configPath), { recursive: true })
fs.writeFileSync(configPath, JSON.stringify({ gateway: { auth: { token: 'gateway-only-token' } } }))
try {
const error = Object.assign(new Error('missing provider auth'), {
stdout: JSON.stringify({
defaultModel: 'provider/model',
resolvedDefault: 'provider/model',
auth: { missingProvidersInUse: ['provider'], unusableProfiles: [] },
}),
})
const result = await resolveNativeCredentialState('openclaw', {
home,
env: {},
executable: '/tmp/openclaw',
execFileFn: async () => { throw error },
})
assert.deepEqual(result, { state: 'missing', source: 'native-auth-status' })
assert.equal(JSON.stringify(result).includes('gateway-only-token'), false)
} finally {
fs.rmSync(home, { recursive: true, force: true })
}
})
test('MiMo readiness relies on the installed CLI and does not inspect private auth storage', async () => {
assert.deepEqual(await resolveNativeCredentialState('mimo', {}), {
state: 'ready', source: 'native-cli',
+198
View File
@@ -89,6 +89,40 @@ test('installed Agents distinguish ready, unverified, and missing credential sta
assert.equal(readinessAgents.find(agent => agent.kind === 'kimi').executable, '/tmp/kimi')
})
test('Custom Agent kinds keep their dynamic label across execution and reload', async (t) => {
const { directory, calls, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const kind = 'custom-0123456789abcdef'
options.detectAgents = async () => [{
kind,
name: 'Repository Reviewer CLI',
executable: '/tmp/review-agent',
version: '1.0.0',
custom: true,
}]
options.agentLabel = selectedKind => selectedKind === kind ? 'Repository Reviewer' : ''
const workspace = new LocalWorkspace(options)
await workspace.refreshAgents()
const group = workspace.createGroup({
conversationType: 'direct',
directAgentKind: kind,
agentKinds: [kind],
workdir: directory,
})
await workspace.sendMessage({ groupId: group.id, text: 'Review this change', targetKinds: [kind] })
assert.match(calls[0].prompt, /as Repository Reviewer\./)
const reply = workspace.snapshot().messages.find(message => message.role === 'agent')
assert.equal(reply.agentKind, kind)
assert.equal(reply.senderName, 'Repository Reviewer')
const reloaded = new LocalWorkspace(options)
assert.equal(reloaded.snapshot().groups[0].directAgentKind, kind)
assert.equal(reloaded.snapshot().messages.find(message => message.role === 'agent').senderName,
'Repository Reviewer')
})
test('native readiness remains visible while a Meldwork Provider profile is active', async (t) => {
const { directory, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
@@ -490,6 +524,124 @@ test('image-only messages persist safe metadata and pass resolved paths to suppo
assert.deepEqual(restored.snapshot().messages[0].attachments, [attachment])
})
test('message deletion persists replies individually and removes a whole group topic from history', async (t) => {
const { directory, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const workspace = new LocalWorkspace(options)
await workspace.refreshAgents()
const group = workspace.createGroup({
name: 'Message cleanup', agentKinds: ['codex', 'hermes'], workdir: directory,
})
const root = workspace.addMessage(group.id, 'user', 'Remove this topic', '', '', null, {
attachments: [{
id: 'attachment-root', name: 'root.png', mimeType: 'image/png', size: 10,
}],
})
const reply = workspace.addMessage(group.id, 'agent', 'Remove only this reply', 'codex', root.id, null, {
attachments: [{
id: 'attachment-reply', name: 'reply.png', mimeType: 'image/png', size: 10,
}],
})
const retainedReply = workspace.addMessage(group.id, 'agent', 'Remove with the topic', 'hermes', root.id)
const topicStatus = workspace.addMessage(
group.id,
'system',
'Topic stopped',
'',
root.id,
{ key: 'system.autoStopped', params: {} },
)
const otherRoot = workspace.addMessage(group.id, 'user', 'Keep this topic')
const otherReply = workspace.addMessage(group.id, 'agent', 'Keep this reply', 'codex', otherRoot.id)
assert.deepEqual(workspace.deleteMessage(group.id, reply.id), {
deletedMessageIds: [reply.id],
})
let messages = workspace.snapshot().messages
assert.equal(messages.some(message => message.id === reply.id), false)
assert.equal(messages.some(message => message.id === retainedReply.id), true)
assert.equal(new LocalWorkspace(options).snapshot().messages.some(message => message.id === reply.id), false)
const deleted = workspace.deleteMessage(group.id, root.id)
assert.deepEqual(new Set(deleted.deletedMessageIds), new Set([
root.id, retainedReply.id, topicStatus.id,
]))
messages = workspace.snapshot().messages
assert.deepEqual(messages.map(message => message.id), [otherRoot.id, otherReply.id])
assert.deepEqual(
new LocalWorkspace(options).snapshot().messages.map(message => message.id),
[otherRoot.id, otherReply.id],
)
})
test('deleting a direct-chat user turn removes its inferred replies without touching later turns', async (t) => {
const { directory, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const workspace = new LocalWorkspace(options)
await workspace.refreshAgents()
const group = workspace.createGroup({
conversationType: 'direct', directAgentKind: 'codex', agentKinds: ['codex'], workdir: directory,
})
const firstRoot = workspace.addMessage(group.id, 'user', 'First turn')
const firstReply = workspace.addMessage(group.id, 'agent', 'First answer', 'codex')
const firstFailure = workspace.addMessage(
group.id,
'system',
'First failure',
'codex',
'',
{ key: 'system.agentCallFailed', params: { agent: 'Codex', reason: 'failed' } },
)
const secondRoot = workspace.addMessage(group.id, 'user', 'Second turn')
const secondReply = workspace.addMessage(group.id, 'agent', 'Second answer', 'codex')
const deleted = workspace.deleteMessage(group.id, firstRoot.id)
assert.deepEqual(new Set(deleted.deletedMessageIds), new Set([
firstRoot.id, firstReply.id, firstFailure.id,
]))
assert.deepEqual(workspace.snapshot().messages.map(message => message.id), [secondRoot.id, secondReply.id])
assert.deepEqual(
new LocalWorkspace(options).snapshot().messages.map(message => message.id),
[secondRoot.id, secondReply.id],
)
})
test('message deletion rejects active, unknown, and cross-conversation targets', async (t) => {
const { directory, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const workspace = new LocalWorkspace(options)
await workspace.refreshAgents()
const firstGroup = workspace.createGroup({
name: 'First', agentKinds: ['codex'], workdir: directory,
})
const secondGroup = workspace.createGroup({
name: 'Second', agentKinds: ['codex'], workdir: directory,
})
const firstMessage = workspace.addMessage(firstGroup.id, 'user', 'First message')
const secondMessage = workspace.addMessage(secondGroup.id, 'user', 'Second message')
assert.throws(
() => workspace.deleteMessage(firstGroup.id, secondMessage.id),
{ message: 'LOCAL_MESSAGE_NOT_FOUND' },
)
assert.throws(
() => workspace.deleteMessage(firstGroup.id, 'missing-message'),
{ message: 'LOCAL_MESSAGE_NOT_FOUND' },
)
workspace.activeRuns.set(
firstGroup.id,
workspace.createRunController('manual', ['codex'], firstMessage.id),
)
assert.throws(
() => workspace.deleteMessage(firstGroup.id, firstMessage.id),
{ message: 'LOCAL_GROUP_RUNNING' },
)
workspace.activeRuns.delete(firstGroup.id)
assert.equal(workspace.snapshot().messages.some(message => message.id === firstMessage.id), true)
})
test('WebP attachment metadata is rejected before a message or Agent run is recorded', async (t) => {
const { directory, calls, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
@@ -1792,6 +1944,52 @@ test('automatic dialogue continues complete rounds until every Agent agrees', as
assert.equal(finished[0].mode, 'auto')
})
for (const acpKind of ['hermes', 'kimi']) {
test(`automatic dialogue starts a fresh ${acpKind} ACP session each round`, async (t) => {
const { directory, calls, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
options.runAgent = async (agent, prompt, workdir, runOptions) => {
calls.push({ agent, prompt, workdir, runOptions })
const agentCallCount = calls.filter(call => call.agent.kind === agent.kind).length
if (agent.kind === acpKind) {
await runOptions.onSessionRef(`${acpKind}-acp-session-${agentCallCount}`, { transport: 'acp' })
}
const consensus = agentCallCount > 1 ? 'agree' : 'continue'
const sessionRef = agent.kind === acpKind
? `${acpKind}-acp-session-${agentCallCount}`
: runOptions.sessionRef || `${agent.kind}-session-${agentCallCount}`
return {
text: `${agent.kind} ${consensus}\n[[ROUNDRELAY_CONSENSUS:${consensus}]]`,
sessionRef,
}
}
const workspace = new LocalWorkspace(options)
await workspace.refreshAgents()
const group = workspace.createGroup({
name: `${acpKind} ACP discussion`, agentKinds: ['codex', acpKind], workdir: directory,
})
const started = await workspace.sendMessage({
groupId: group.id,
text: 'Check ACP session isolation',
mode: 'auto',
maxRounds: 2,
})
assert.equal(started.started, true)
await workspace.activeRuns.get(group.id).promise
const acpCalls = calls.filter(call => call.agent.kind === acpKind)
assert.deepEqual(acpCalls.map(call => call.runOptions.sessionRef), ['', ''])
assert.equal(acpCalls[1].runOptions.sessionTransport, '')
assert.match(acpCalls[1].prompt, new RegExp(`${acpKind} continue`))
assert.equal(
workspace.state.sessions[workspace.sessionKey(group.id, acpKind)],
`${acpKind}-acp-session-2`,
)
assert.equal(workspace.state.sessionMeta[workspace.sessionKey(group.id, acpKind)].transport, 'acp')
})
}
test('automatic dialogue queues only the explicitly targeted group members', async (t) => {
const { directory, calls, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
+271 -19
View File
@@ -14,6 +14,7 @@ const PROVIDER_METADATA = Object.freeze({
})
const PROVIDER_AGENT_KINDS = Object.freeze([
'codex', 'hermes', 'openclaw', 'workbuddy', 'kimi', 'mimo', 'claude', 'gemini', 'opencode', 'qwen',
'opencodereview',
])
function pngHeader(width = 1, height = 1) {
@@ -32,6 +33,7 @@ const LOCAL_IPC_CHANNELS = Object.freeze([
'local-workspace:create-group',
'local-workspace:update-group',
'local-workspace:delete-group',
'local-workspace:delete-message',
'local-workspace:send',
'local-workspace:stop',
'local-workspace:pick-directory',
@@ -42,8 +44,10 @@ const LOCAL_IPC_CHANNELS = Object.freeze([
'local-agent-installer:start',
'local-agent-installer:cancel',
'local-agent-installer:set-sidebar-visibility',
'local-attachments:pick-images',
'local-attachments:import-image',
'local-custom-agent:create',
'local-custom-agent:delete',
'local-attachments:pick',
'local-attachments:import',
'local-attachments:preview',
'local-attachments:discard',
'local-agent-provider:status',
@@ -85,16 +89,20 @@ function loadMain(userData, options = {}) {
const windows = []
const workspaceInstances = []
const installerInstances = []
const customAgentStoreInstances = []
const providerInstances = []
const knowledgeBaseStoreInstances = []
const knowledgeBaseResolveCalls = []
const attachmentInstances = []
const skillCatalogInstances = []
const runAgentCalls = []
const customAgentRunCalls = []
const externalUrls = []
const dialogCalls = []
const permissionHandlers = {}
const nativeImageCalls = []
const nativeImagePathCalls = []
const dockIconCalls = []
const notificationInstances = []
const protocolHandlers = new Map()
const registeredSchemes = []
@@ -118,6 +126,7 @@ function loadMain(userData, options = {}) {
this.maxConcurrentRefreshes = 0
this.clearRuntimeCredentialFailuresCount = 0
this.stopCount = 0
this.deleteMessageCalls = []
workspaceInstances.push(this)
}
@@ -144,6 +153,22 @@ function loadMain(userData, options = {}) {
this.state.groups = this.state.groups.filter(group => group.id !== groupId)
this.state.messages = this.state.messages.filter(message => message.groupId !== groupId)
}
deleteMessage(groupId, messageId) {
this.deleteMessageCalls.push([groupId, messageId])
const target = this.state.messages.find(message => (
message.groupId === groupId && message.id === messageId
))
if (!target) throw new Error('LOCAL_MESSAGE_NOT_FOUND')
const deletedIds = new Set([target.id])
if (target.role === 'user' && !target.threadRootId) {
for (const message of this.state.messages) {
if (message.groupId === groupId && message.threadRootId === target.id) {
deletedIds.add(message.id)
}
}
}
this.state.messages = this.state.messages.filter(message => !deletedIds.has(message.id))
}
sendMessage() { return Promise.resolve(this.snapshot()) }
startAuto() { return this.snapshot() }
stop() { return false }
@@ -186,6 +211,78 @@ function loadMain(userData, options = {}) {
}
}
class TestCustomAgentStore {
constructor(input) {
this.input = input
this.created = []
this.removed = []
this.detectCount = 0
this.agents = structuredClone(options.customAgents || [])
customAgentStoreInstances.push(this)
}
has(kind) {
return this.agents.some(agent => agent.kind === kind)
}
label(kind) {
return this.agents.find(agent => agent.kind === kind)?.label || ''
}
async detectAgents() {
this.detectCount += 1
return this.agents.filter(agent => agent.installed !== false).map(agent => ({
...agent,
executable: agent.executable || `/private/${agent.commandName || 'custom-agent'}`,
}))
}
async catalog() {
return this.agents.map(({ executable: _executable, args: _args, ...agent }) => ({
installed: agent.installed !== false,
installSupported: false,
providerCompatible: false,
providerMode: 'custom',
imageAttachmentLimit: 0,
...agent,
custom: true,
}))
}
create(input, executable) {
this.created.push({ input, executable })
const agent = {
kind: 'custom-0123456789abcdef',
label: String(input?.label || ''),
description: String(input?.description || ''),
commandName: path.basename(executable),
promptMode: input?.promptMode,
custom: true,
installed: true,
installSupported: false,
providerCompatible: false,
providerMode: 'custom',
imageAttachmentLimit: 0,
version: '1.0.0',
executable,
}
this.agents.push(agent)
const { executable: _privatePath, ...profile } = agent
return profile
}
remove(kind) {
this.removed.push(kind)
this.agents = this.agents.filter(agent => agent.kind !== kind)
return true
}
run(...args) {
customAgentRunCalls.push(args)
return Promise.resolve({ text: 'custom reply', sessionRef: '' })
}
}
class TestAttachmentStore {
constructor(input) {
attachmentConstructionCount += 1
@@ -419,6 +516,13 @@ function loadMain(userData, options = {}) {
}
TestNotification.isSupported = () => options.notificationSupported !== false
const iconImage = {
isEmpty: () => false,
getSize: () => ({ width: 512, height: 512 }),
resize: () => iconImage,
toDataURL: () => 'data:image/png;base64,AQID',
}
const electron = {
app: {
getPath: name => name === 'documents'
@@ -434,6 +538,9 @@ function loadMain(userData, options = {}) {
return options.singleInstanceLock !== false
},
whenReady: () => ({ then: listener => { readyCallback = listener } }),
dock: {
setIcon: icon => dockIconCalls.push(icon),
},
},
BrowserWindow: TestBrowserWindow,
Notification: TestNotification,
@@ -462,6 +569,10 @@ function loadMain(userData, options = {}) {
},
},
nativeImage: {
createFromPath: filename => {
nativeImagePathCalls.push(filename)
return iconImage
},
createFromBuffer: (bytes) => {
nativeImageCalls.push(bytes)
const image = {
@@ -497,6 +608,7 @@ function loadMain(userData, options = {}) {
},
},
'./agent-installer.cjs': { AgentInstaller: TestInstaller },
'./custom-agent-store.cjs': { CustomAgentStore: TestCustomAgentStore },
'./attachment-store.cjs': { AttachmentStore: TestAttachmentStore },
'./local-agent-readiness.cjs': {
nativeCredentialEnvironment: kind => options.nativeEnvironment?.(kind) || {},
@@ -560,7 +672,11 @@ function loadMain(userData, options = {}) {
ipcHandlers,
ipcListeners,
installerInstances,
customAgentStoreInstances,
customAgentRunCalls,
dockIconCalls,
nativeImageCalls,
nativeImagePathCalls,
notificationInstances,
knowledgeBaseResolveCalls,
knowledgeBaseStoreInstances,
@@ -701,6 +817,8 @@ test('ready activates one fixed local workspace and loads the bundled frontend',
assert.equal(harness.workspaceInstances[0].refreshCount, 0)
assert.equal(harness.providerInstances[0].input.storagePath,
path.join(directory, 'roundrelay-provider.json'))
assert.equal(harness.customAgentStoreInstances[0].input.storagePath,
path.join(directory, 'roundrelay-custom-agents.json'))
assert.deepEqual(
[...harness.providerInstances[0].input.allowedKinds].sort(),
[...PROVIDER_AGENT_KINDS].sort(),
@@ -713,12 +831,17 @@ test('ready activates one fixed local workspace and loads the bundled frontend',
path.join(directory, 'Home'))
await harness.workspaceInstances[0].input.detectAgents()
assert.equal(harness.installerInstances[0].detectedCount, 1)
assert.equal(harness.customAgentStoreInstances[0].detectCount, 1)
assert.equal(typeof harness.workspaceInstances[0].input.resolveAttachments, 'function')
assert.equal(typeof harness.workspaceInstances[0].input.validateSkillSelections, 'function')
assert.equal(typeof harness.workspaceInstances[0].input.validateKnowledgeBaseSelections, 'function')
assert.equal(typeof harness.workspaceInstances[0].input.imageAttachmentLimit, 'function')
assert.equal(typeof harness.workspaceInstances[0].input.agentLabel, 'function')
assert.deepEqual(harness.attachmentInstances[0].cleanupCalls, [[]])
assert.equal(harness.windows.length, 1)
assert.equal(harness.windows[0].input.icon?.isEmpty?.(), false)
assert.match(harness.nativeImagePathCalls[0], /frontend[\\/]public[\\/]logos[\\/]meldwork-app\.png$/)
if (process.platform === 'darwin') assert.equal(harness.dockIconCalls.length, 1)
assert.equal(
harness.windows[0].input.backgroundColor,
process.platform === 'darwin' ? '#e7edef' : '#f3f6f8',
@@ -740,6 +863,104 @@ test('ready activates one fixed local workspace and loads the bundled frontend',
assert.equal(harness.ipcListeners.size, 0)
})
test('Custom Agent IPC keeps executable paths private and refreshes the local catalog', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'roundrelay-custom-agent-ipc-'))
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const selectedExecutable = '/private/tools/review-agent'
const { harness } = loadMain(directory, {
dialogResult: { canceled: false, filePaths: [selectedExecutable] },
})
await harness.ready()
const result = await harness.ipcHandlers.get('local-custom-agent:create')(
harness.event(),
{
label: 'Review Agent',
description: 'Reviews repository changes.',
args: ['review', '--format=text'],
promptMode: 'stdin',
executable: '/renderer/must-not-control-this',
},
)
assert.equal(result.canceled, false)
assert.equal(result.agent.kind, 'custom-0123456789abcdef')
assert.equal(result.agent.commandName, 'review-agent')
assert.equal('executable' in result.agent, false)
assert.equal('args' in result.agent, false)
assert.doesNotMatch(JSON.stringify(result), /private\/tools|renderer\/must-not-control/)
assert.deepEqual(harness.customAgentStoreInstances[0].created, [{
input: {
label: 'Review Agent',
description: 'Reviews repository changes.',
args: ['review', '--format=text'],
promptMode: 'stdin',
},
executable: selectedExecutable,
}])
assert.equal(harness.workspaceInstances[0].refreshCount, 1)
const customReply = await harness.workspaceInstances[0].input.runAgent(
{ kind: result.agent.kind },
'Review this change',
'/tmp/project',
{ signal: new AbortController().signal },
)
assert.deepEqual(customReply, { text: 'custom reply', sessionRef: '' })
assert.equal(harness.customAgentRunCalls.length, 1)
assert.equal(harness.runAgentCalls.length, 0)
const catalog = await harness.ipcHandlers.get('local-agent-installer:catalog')(harness.event())
const custom = catalog.agents.find(agent => agent.kind === 'custom-0123456789abcdef')
assert.equal(custom.custom, true)
assert.equal(custom.commandName, 'review-agent')
assert.equal('executable' in custom, false)
assert.equal('args' in custom, false)
assert.deepEqual(
await harness.ipcHandlers.get('local-agent-installer:skills')(
harness.event(), 'custom-0123456789abcdef',
),
{ supported: false, skills: [], total: 0, limit: 0 },
)
})
test('Custom Agent deletion is blocked while a local conversation references it', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'roundrelay-custom-agent-delete-'))
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const kind = 'custom-0123456789abcdef'
const { harness } = loadMain(directory, {
customAgents: [{
kind,
label: 'Review Agent',
description: '',
commandName: 'review-agent',
promptMode: 'stdin',
custom: true,
installed: true,
}],
workspaceSnapshot: {
agents: [],
groups: [{ id: 'group-1', agentKinds: [kind] }],
messages: [],
localOnly: true,
},
})
await harness.ready()
await assert.rejects(
harness.ipcHandlers.get('local-custom-agent:delete')(harness.event(), kind),
{ message: 'CUSTOM_AGENT_IN_USE' },
)
assert.deepEqual(harness.customAgentStoreInstances[0].removed, [])
harness.workspaceInstances[0].state.groups = []
assert.deepEqual(
await harness.ipcHandlers.get('local-custom-agent:delete')(harness.event(), kind),
{ deleted: true, kind },
)
assert.deepEqual(harness.customAgentStoreInstances[0].removed, [kind])
})
test('a second desktop launch restores and focuses the existing window', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'roundrelay-single-instance-focus-'))
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
@@ -798,10 +1019,9 @@ test('run completion uses sanitized renderer events and a localized background n
}])
assert.equal(harness.notificationInstances.length, 1)
const notification = harness.notificationInstances[0]
assert.deepEqual(notification.input, {
title: 'Meldwork',
body: '会话运行已结束',
})
assert.equal(notification.input.title, 'Meldwork')
assert.equal(notification.input.body, '会话运行已结束')
assert.equal(notification.input.icon?.isEmpty?.(), false)
assert.equal(notification.showCount, 1)
assert.doesNotMatch(
JSON.stringify(notification.input),
@@ -894,10 +1114,9 @@ test('a completion notification restores a closed window and opens its local gro
})
assert.equal(harness.notificationInstances.length, 1)
assert.deepEqual(harness.notificationInstances[0].input, {
title: 'Meldwork',
body: 'Conversation run finished',
})
assert.equal(harness.notificationInstances[0].input.title, 'Meldwork')
assert.equal(harness.notificationInstances[0].input.body, 'Conversation run finished')
assert.equal(harness.notificationInstances[0].input.icon?.isEmpty?.(), false)
assert.equal(originalWindow.webContents.sent.length, 0)
harness.notificationInstances[0].emit('click')
@@ -930,8 +1149,8 @@ test('attachment storage initialization failure does not block text chat startup
),
)
for (const [channel, args] of [
['local-attachments:pick-images', []],
['local-attachments:import-image', [{ name: 'x.png', mimeType: 'image/png', bytes: [1] }]],
['local-attachments:pick', []],
['local-attachments:import', [{ name: 'x.png', mimeType: 'image/png', bytes: [1] }]],
['local-attachments:preview', ['attachment-1']],
['local-attachments:discard', [['attachment-1']]],
]) {
@@ -1000,7 +1219,7 @@ test('startup attachment cleanup failure keeps the validated store available', a
assert.deepEqual(harness.attachmentInstances[0].cleanupCalls, [[]])
await assert.doesNotReject(
harness.ipcHandlers.get('local-attachments:pick-images')(harness.event(), 1),
harness.ipcHandlers.get('local-attachments:pick')(harness.event(), 1),
)
})
@@ -1279,7 +1498,7 @@ test('image IPC returns bounded previews and never exposes imported file paths',
})
await harness.ready()
const picked = await harness.ipcHandlers.get('local-attachments:pick-images')(
const picked = await harness.ipcHandlers.get('local-attachments:pick')(
harness.event(), 2,
)
assert.equal(picked.attachments.length, 2)
@@ -1287,7 +1506,7 @@ test('image IPC returns bounded previews and never exposes imported file paths',
assert.deepEqual(harness.attachmentInstances[0].importedFiles, [first, second])
assert.deepEqual(harness.dialogCalls[0][1], {
properties: ['openFile', 'multiSelections'],
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg'] }],
filters: [{ name: 'Media', extensions: ['png', 'jpg', 'jpeg', 'mp3', 'wav', 'm4a', 'mp4', 'mov', 'webm'] }],
})
for (const attachment of picked.attachments) {
assert.deepEqual(Object.keys(attachment).sort(), [
@@ -1297,7 +1516,7 @@ test('image IPC returns bounded previews and never exposes imported file paths',
assert.equal(JSON.stringify(attachment).includes(directory), false)
}
const imported = await harness.ipcHandlers.get('local-attachments:import-image')(
const imported = await harness.ipcHandlers.get('local-attachments:import')(
harness.event(),
{ name: 'paste.png', mimeType: 'image/png', bytes: [1, 2, 3] },
)
@@ -1329,7 +1548,7 @@ test('image picker does not import files after application shutdown starts', asy
const { harness } = loadMain(directory, { dialogResult: dialogResult.promise })
await harness.ready()
const picking = harness.ipcHandlers.get('local-attachments:pick-images')(harness.event())
const picking = harness.ipcHandlers.get('local-attachments:pick')(harness.event())
await waitFor(() => harness.dialogCalls.length === 1)
harness.appListeners.get('before-quit')({ preventDefault: () => {} })
dialogResult.resolve({ canceled: false, filePaths: [selected] })
@@ -1349,7 +1568,7 @@ test('image dimensions are bounded before nativeImage decoding and checked again
await oversizedBeforeDecode.harness.ready()
await assert.rejects(
async () => oversizedBeforeDecode.harness.ipcHandlers.get('local-attachments:import-image')(
async () => oversizedBeforeDecode.harness.ipcHandlers.get('local-attachments:import')(
oversizedBeforeDecode.harness.event(),
{ name: 'large.png', mimeType: 'image/png', bytes: [1, 2, 3] },
),
@@ -1364,7 +1583,7 @@ test('image dimensions are bounded before nativeImage decoding and checked again
})
await oversizedAfterDecode.harness.ready()
await assert.rejects(
async () => oversizedAfterDecode.harness.ipcHandlers.get('local-attachments:import-image')(
async () => oversizedAfterDecode.harness.ipcHandlers.get('local-attachments:import')(
oversizedAfterDecode.harness.event(),
{ name: 'decoded-large.png', mimeType: 'image/png', bytes: [1, 2, 3] },
),
@@ -1437,6 +1656,39 @@ test('deleting a conversation removes only attachments no longer referenced else
assert.deepEqual(harness.attachmentInstances[0].discarded, [['only-one']])
})
test('deleting a message normalizes identifiers and discards only newly unreferenced attachments', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'roundrelay-delete-message-attachments-'))
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const { harness } = loadMain(directory, {
workspaceSnapshot: {
agents: [],
groups: [{ id: '42' }],
messages: [
{
id: '7', groupId: '42', role: 'agent',
attachments: [{ id: 'shared' }, { id: 'only-message' }],
},
{
id: '8', groupId: '42', role: 'agent',
attachments: [{ id: 'shared' }],
},
],
localOnly: true,
},
})
await harness.ready()
const snapshot = await harness.ipcHandlers.get('local-workspace:delete-message')(
harness.event(), 42, 7,
)
assert.deepEqual(harness.workspaceInstances[0].deleteMessageCalls, [['42', '7']])
assert.deepEqual(snapshot.messages, [{
id: '8', groupId: '42', role: 'agent', attachments: [{ id: 'shared' }],
}])
assert.deepEqual(harness.attachmentInstances[0].discarded, [['only-message']])
})
test('configured Provider is injected only through local Agent execution options', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'roundrelay-provider-routing-'))
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
+17
View File
@@ -51,6 +51,23 @@ test('managed runtime isolates OpenClaw state and keeps the API key out of confi
assert.equal(fs.statSync(result.env.OPENCLAW_CONFIG_PATH).mode & 0o777, 0o600)
})
test('managed runtime defensively normalizes chat completion endpoints to the API root', (t) => {
const { directory, workdir } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const result = managedOpenClawOptions({
storageRoot: directory,
workdir,
provider: {
...provider(),
OPENAI_BASE_URL: 'https://api.example.com/v1/chat/completions/',
},
})
const config = JSON.parse(fs.readFileSync(result.env.OPENCLAW_CONFIG_PATH, 'utf8'))
assert.equal(config.models.providers['roundrelay-desktop'].baseUrl, 'https://api.example.com/v1')
})
test('write authorization uses an immutable config and preserves the topic state', (t) => {
const { directory, workdir } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
+51 -10
View File
@@ -62,13 +62,19 @@ test('local preload exposes the local-only RoundRelay API and narrow Provider me
assert.deepEqual(
Object.keys(api.localWorkspace).sort(),
[
'createGroup', 'defaultDirectory', 'deleteGroup', 'get', 'onChanged',
'createGroup', 'defaultDirectory', 'deleteGroup', 'deleteMessage', 'get', 'onChanged',
'onOpenGroup', 'onRunEvent', 'onRunFinished', 'pickDirectory', 'refreshAgents', 'send',
'stop', 'updateGroup',
].sort(),
)
assert.equal('configure' in api.localWorkspace, false)
assert.equal('cancelConfigure' in api.localWorkspace, false)
await api.localWorkspace.deleteMessage('group-1', 'message-1')
assert.deepEqual(invocations, [{
channel: 'local-workspace:delete-message',
args: ['group-1', 'message-1'],
}])
invocations.length = 0
assert.deepEqual(
Object.keys(api.localAgentProvider).sort(),
['activate', 'delete', 'probe', 'save', 'status'],
@@ -165,28 +171,62 @@ test('local preload exposes only the narrow Agent installer methods', async () =
])
})
test('local preload exposes only create and delete for Custom Agents', async () => {
const { api, invocations } = loadPreload('file:')
assert.equal(Object.isFrozen(api.customAgent), true)
assert.deepEqual(Object.keys(api.customAgent).sort(), ['create', 'delete'])
assert.equal('list' in api.customAgent, false)
assert.equal('run' in api.customAgent, false)
assert.equal('pickExecutable' in api.customAgent, false)
await api.customAgent.create({
label: 'Review Agent',
description: 'Reviews changes.',
args: ['review'],
promptMode: 'stdin',
})
await api.customAgent.delete('custom-0123456789abcdef')
assert.deepEqual(invocations, [
{
channel: 'local-custom-agent:create',
args: [{
label: 'Review Agent',
description: 'Reviews changes.',
args: ['review'],
promptMode: 'stdin',
}],
},
{
channel: 'local-custom-agent:delete',
args: ['custom-0123456789abcdef'],
},
])
})
test('local preload exposes image import without filesystem read or path resolution methods', async () => {
const { api, invocations } = loadPreload('file:')
assert.equal(Object.isFrozen(api.localAttachments), true)
assert.deepEqual(
Object.keys(api.localAttachments).sort(),
['discard', 'importImage', 'pickImages', 'preview'],
['discard', 'importAttachment', 'pickAttachments', 'preview'],
)
assert.equal('read' in api.localAttachments, false)
assert.equal('resolve' in api.localAttachments, false)
const bytes = Uint8Array.from([1, 2, 3])
await api.localAttachments.pickImages(2)
await api.localAttachments.importImage({
await api.localAttachments.pickAttachments(2)
await api.localAttachments.importAttachment({
name: 'diagram.png', mimeType: 'image/png', bytes,
})
bytes[0] = 9
await api.localAttachments.preview('attachment-1')
await api.localAttachments.discard(['attachment-1'])
assert.deepEqual(invocations, [
{ channel: 'local-attachments:pick-images', args: [2] },
{ channel: 'local-attachments:pick', args: [2] },
{
channel: 'local-attachments:import-image',
channel: 'local-attachments:import',
args: [{ name: 'diagram.png', mimeType: 'image/png', bytes: Uint8Array.from([1, 2, 3]) }],
},
{ channel: 'local-attachments:preview', args: ['attachment-1'] },
@@ -198,25 +238,25 @@ test('local preload rejects unbounded or unsupported renderer image payloads bef
const { api, invocations } = loadPreload('file:')
assert.throws(
() => api.localAttachments.importImage({
() => api.localAttachments.importAttachment({
name: 'array.png', mimeType: 'image/png', bytes: [1, 2, 3],
}),
{ code: 'LOCAL_ATTACHMENT_BYTES_INVALID' },
)
assert.throws(
() => api.localAttachments.importImage({
() => api.localAttachments.importAttachment({
name: 'animation.gif', mimeType: 'image/gif', bytes: Uint8Array.from([1]),
}),
{ code: 'LOCAL_ATTACHMENT_TYPE_UNSUPPORTED' },
)
assert.throws(
() => api.localAttachments.importImage({
() => api.localAttachments.importAttachment({
name: 'preview.webp', mimeType: 'image/webp', bytes: Uint8Array.from([1]),
}),
{ code: 'LOCAL_ATTACHMENT_TYPE_UNSUPPORTED' },
)
assert.throws(
() => api.localAttachments.importImage({
() => api.localAttachments.importAttachment({
name: 'large.png', mimeType: 'image/png', bytes: new Uint8Array((8 * 1024 * 1024) + 1),
}),
{ code: 'LOCAL_ATTACHMENT_TOO_LARGE' },
@@ -233,6 +273,7 @@ test('remote preload does not expose local credentials, workspace, or installer
assert.equal('getAuthToken' in api, false)
assert.equal('localWorkspace' in api, false)
assert.equal('agentInstaller' in api, false)
assert.equal('customAgent' in api, false)
assert.equal('localAttachments' in api, false)
assert.equal('localAgentProvider' in api, false)
assert.equal('localKnowledgeBase' in api, false)
+50 -2
View File
@@ -25,6 +25,11 @@ const QWEN_OFFICIAL_PROVIDER = Object.freeze({
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
model: 'qwen3-coder-plus',
})
const OCR_OFFICIAL_PROVIDER = Object.freeze({
provider: 'OpenAI API',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-5',
})
function credential(apiKey = 'test-provider-key', metadata = PROVIDER, preset = 'custom') {
return { ...metadata, apiKey, preset }
@@ -55,7 +60,7 @@ function encryptedSafeStorage(available = true) {
}
}
function fixture(safeStorage = encryptedSafeStorage(), allowedKinds = ['hermes', 'qwen']) {
function fixture(safeStorage = encryptedSafeStorage(), allowedKinds = ['hermes', 'qwen', 'opencodereview']) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'roundrelay-provider-'))
const storagePath = path.join(directory, 'roundrelay-provider.json')
return {
@@ -165,6 +170,10 @@ test('preset metadata must match canonical OpenRouter and Agent official Provide
store.save('qwen', credential('official-key', QWEN_OFFICIAL_PROVIDER, 'official')),
configuredStatus(QWEN_OFFICIAL_PROVIDER, 'official'),
)
assert.deepEqual(
store.save('opencodereview', credential('official-key', OCR_OFFICIAL_PROVIDER, 'official')),
configuredStatus(OCR_OFFICIAL_PROVIDER, 'official'),
)
assert.throws(
() => store.save('hermes', credential('key', {
...HERMES_OFFICIAL_PROVIDER,
@@ -202,6 +211,44 @@ test('custom preset keeps accepting secure arbitrary and loopback endpoints', (t
}, 'custom')))
})
for (const kind of [
'codex', 'hermes', 'openclaw', 'workbuddy', 'kimi', 'mimo',
'claude', 'gemini', 'opencode', 'qwen', 'opencodereview',
]) {
test(`${kind} Provider endpoints are stored as API roots`, (t) => {
const { directory, store } = fixture(encryptedSafeStorage(), [kind])
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const status = store.save(kind, credential(`${kind}-key`, {
...PROVIDER,
baseUrl: 'https://api.example.com/v1/chat/completions/',
}))
assert.equal(status.baseUrl, 'https://api.example.com/v1')
assert.equal(status.profiles.custom.baseUrl, 'https://api.example.com/v1')
assert.equal(store.envForAgent(kind).OPENAI_BASE_URL, 'https://api.example.com/v1')
})
test(`legacy ${kind} profiles normalize duplicated chat completion endpoints on read`, (t) => {
const { directory, storagePath, store } = fixture(encryptedSafeStorage(), [kind])
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const encrypted = encryptedSafeStorage().encryptString(`legacy-${kind}-key`).toString('base64')
fs.writeFileSync(storagePath, JSON.stringify({
version: 2,
agents: {
[kind]: {
...PROVIDER,
baseUrl: 'https://api.example.com/v1/chat/completions',
encrypted,
},
},
}), { mode: 0o600 })
assert.equal(store.status(kind).baseUrl, 'https://api.example.com/v1')
assert.equal(store.envForAgent(kind).OPENAI_BASE_URL, 'https://api.example.com/v1')
})
}
test('envForAgent decrypts the configured Provider', (t) => {
const { directory, store } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
@@ -349,11 +396,12 @@ test('legacy shared credentials migrate into independent Agent entries on mutati
assert.equal(store.status('hermes').configured, true)
assert.equal(store.status('qwen').configured, true)
assert.equal(store.status('opencodereview').configured, true)
store.save('qwen', credential('new-qwen-key', { ...PROVIDER, model: 'new-qwen-model' }))
const migrated = JSON.parse(fs.readFileSync(storagePath, 'utf8'))
assert.equal(migrated.version, 3)
assert.deepEqual(Object.keys(migrated.agents).sort(), ['hermes', 'qwen'])
assert.deepEqual(Object.keys(migrated.agents).sort(), ['hermes', 'opencodereview', 'qwen'])
assert.equal(store.envForAgent('hermes').OPENAI_API_KEY, 'legacy-key')
assert.equal(store.envForAgent('qwen').OPENAI_API_KEY, 'new-qwen-key')
})
+1 -1
View File
@@ -28,7 +28,7 @@ Meldwork embeds local Agent automation and an Agent installer. There are no webh
**Executable/tool surface:**
- Main may spawn only detected executables for Codex, Hermes, OpenClaw, WorkBuddy, Kimi Code, MiMo Code, Claude Code, Qwen Code, Gemini CLI, and OpenCode.
- Main may spawn only detected executables for Codex, Hermes, OpenClaw, WorkBuddy, Kimi Code, MiMo Code, Claude Code, Qwen Code, Gemini CLI, OpenCode, and OpenCodeReview.
- Command shapes are code-defined in `desktop/src/cli-adapters.cjs`; prompt text does not select an arbitrary executable or shell command.
- Kimi read-only conversations use Agent Client Protocol `plan` mode. Write-authorized conversations retain Kimi's native stream-JSON prompt mode.
- OpenClaw receives an explicit tool allow/deny configuration. Execution, process, browser, gateway, cron, messaging, session spawning, and subagent tools are denied; write/edit/apply-patch are added only when the conversation permits writes.
+2
View File
@@ -30,6 +30,7 @@ Meldwork detects and forwards only credential variables associated with the sele
| Qwen Code | `DASHSCOPE_API_KEY`, `OPENAI_API_KEY` |
| Gemini CLI | `GEMINI_API_KEY`, `GOOGLE_API_KEY` |
| OpenCode | `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY` |
| OpenCodeReview | `OCR_LLM_TOKEN`, `OPENAI_API_KEY` |
These credentials are owned and rotated through the upstream CLI/provider, not Meldwork. Readiness checks report only `ready`, `missing`, or `unknown`; they do not return credential values.
@@ -42,6 +43,7 @@ Main derives these at invocation time; users should not set them as Meldwork con
| `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL` | Normalized configured Provider values for compatible Agents |
| `HERMES_INFERENCE_MODEL` | Mirrors the configured model for Hermes |
| `CODEBUDDY_MODEL`, `CODEBUDDY_API_KEY`, `CODEBUDDY_BASE_URL` | WorkBuddy Provider mapping |
| `OCR_LLM_URL`, `OCR_LLM_TOKEN`, `OCR_LLM_MODEL`, `OCR_USE_ANTHROPIC` | OpenCodeReview Provider mapping |
| `ROUNDRELAY_OPENCLAW_API_KEY` | Secret environment reference used by generated OpenClaw config |
| `OPENCLAW_HOME`, `OPENCLAW_STATE_DIR`, `OPENCLAW_CONFIG_PATH`, `OPENCLAW_WORKSPACE_DIR` | Isolated managed OpenClaw runtime paths |
| `HERMES_EXEC_ASK`, `HERMES_YOLO_MODE`, `OPENCODE_PERMISSION` | Runtime safety overrides |
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
<title id="title">Custom Agent</title>
<rect width="64" height="64" rx="8" fill="#F4F6F7"/>
<path d="M18 19h28a5 5 0 0 1 5 5v20a5 5 0 0 1-5 5H18a5 5 0 0 1-5-5V24a5 5 0 0 1 5-5Z" fill="#20292C"/>
<path d="m21 29 6 5-6 5M31 40h11" fill="none" stroke="#F4F6F7" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="49" cy="17" r="6" fill="#EF6D52"/>
</svg>

After

Width:  |  Height:  |  Size: 486 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="OpenCodeReview">
<rect width="64" height="64" rx="14" fill="#111827"/>
<path d="M18 17h28a4 4 0 0 1 4 4v22a4 4 0 0 1-4 4H18a4 4 0 0 1-4-4V21a4 4 0 0 1 4-4Z" fill="#f9fafb"/>
<path d="M22 26h20M22 33h12M22 40h18" stroke="#111827" stroke-width="4" stroke-linecap="round"/>
<path d="m43 33 4 4 8-10" fill="none" stroke="#2563eb" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 490 B

+756 -84
View File
File diff suppressed because it is too large. Load diff
+493 -52
View File
@@ -65,6 +65,7 @@ function createBridge() {
})),
updateGroup: vi.fn(async (_groupId, input) => cloneInput(input)),
deleteGroup: vi.fn(async () => snapshot()),
deleteMessage: vi.fn(async () => snapshot()),
send: vi.fn(async input => {
cloneInput(input)
return snapshot()
@@ -112,6 +113,10 @@ function createBridge() {
cancel: vi.fn(async () => true),
onChanged: vi.fn(() => vi.fn()),
}
const customAgent = {
create: vi.fn(async () => ({ canceled: true })),
delete: vi.fn(async kind => ({ deleted: true, kind })),
}
const localAgentProvider = {
status: vi.fn(async kind => ({
kind,
@@ -289,8 +294,8 @@ function createBridge() {
])),
}
const localAttachments = {
pickImages: vi.fn(async () => ({ attachments: [] })),
importImage: vi.fn(async input => ({
pickAttachments: vi.fn(async () => ({ attachments: [] })),
importAttachment: vi.fn(async input => ({
id: 'attachment-1',
name: input.name,
mimeType: input.mimeType,
@@ -307,7 +312,14 @@ function createBridge() {
discard: vi.fn(async ids => ({ discardedIds: [...ids], retainedIds: [] })),
}
return {
bridge: { localWorkspace: workspace, agentInstaller, localAgentProvider, localKnowledgeBase, localAttachments },
bridge: {
localWorkspace: workspace,
agentInstaller,
customAgent,
localAgentProvider,
localKnowledgeBase,
localAttachments,
},
state,
emitWorkspaceChanged(value = state) {
workspaceChanged?.(snapshot(value))
@@ -358,7 +370,7 @@ afterEach(() => {
})
describe('RoundRelay workbench', () => {
it('lets first-run users browse the guide while detection gates only the final action', async () => {
it('enables onboarding completion as soon as Agent detection finishes on any slide', async () => {
vi.useFakeTimers()
localStorage.removeItem('roundrelay-onboarding-seen-v1')
let finishDetection
@@ -379,22 +391,20 @@ describe('RoundRelay workbench', () => {
await wrapper.findAll('.onboarding-dot')[1].trigger('click')
expect(wrapper.get('.onboarding-slide img').attributes('src')).toContain('provider-setup-v2.png')
expect(wrapper.findAll('.onboarding-dot')[1].attributes('aria-current')).toBe('step')
await vi.advanceTimersByTimeAsync(5_000)
await flushPromises()
expect(wrapper.get('.onboarding-slide img').attributes('src')).toContain('agent-collaboration.png')
await wrapper.findAll('.onboarding-dot')[4].trigger('click')
expect(wrapper.get('.onboarding-slide img').attributes('src')).toContain('auto-discussion-v2.png')
expect(wrapper.get('.onboarding-primary').attributes()).toHaveProperty('disabled')
expect(wrapper.get('.onboarding-primary').text()).toContain('Detecting local Agents')
finishDetection(structuredClone(state))
await flushPromises()
expect(wrapper.get('.onboarding-slide img').attributes('src')).toContain('provider-setup-v2.png')
expect(wrapper.get('.onboarding-primary').attributes()).not.toHaveProperty('disabled')
expect(wrapper.get('.onboarding-primary').text()).toContain('Start using')
await vi.advanceTimersByTimeAsync(3_200)
await flushPromises()
expect(wrapper.get('.onboarding-slide img').attributes('src')).toContain('agent-collaboration.png')
await wrapper.get('.onboarding-primary').trigger('click')
expect(wrapper.find('.onboarding-dialog').exists()).toBe(false)
expect(localStorage.getItem('roundrelay-onboarding-seen-v1')).toBe('1')
@@ -402,6 +412,20 @@ describe('RoundRelay workbench', () => {
wrapper.unmount()
})
it('keeps the onboarding frame stable with faster transitions and no internal dividers', () => {
const source = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8')
const appSource = readFileSync(resolve(process.cwd(), 'src/App.vue'), 'utf8')
expect(appSource).toContain('const ONBOARDING_SLIDE_MS = 3200')
expect(appSource).toContain('const onboardingReady = computed(() => !onboardingDetecting.value)')
expect(source).toMatch(/\.onboarding-dialog\s*\{[^}]*height:\s*min\(700px, calc\(100dvh - 48px\)\);/s)
expect(source).toMatch(/\.onboarding-slide-viewport\s*\{[^}]*position:\s*relative;[^}]*overflow:\s*hidden;/s)
expect(source).toMatch(/\.onboarding-slide\s*\{[^}]*position:\s*absolute;[^}]*inset:\s*0;[^}]*grid-template-rows:\s*minmax\(0, 1fr\) 148px;/s)
expect(source).toMatch(/\.onboarding-slide > img\s*\{[^}]*height:\s*100%;[^}]*border-bottom:\s*0;/s)
expect(source).toMatch(/\.onboarding-footer\s*\{[^}]*border-top:\s*0;/s)
expect(source).toMatch(/\.onboarding-slide-enter-active,[^{]+\{[^}]*transition:\s*opacity 0\.1s ease;/s)
})
it('dismisses first-run onboarding with Escape and releases the body scroll lock', async () => {
localStorage.removeItem('roundrelay-onboarding-seen-v1')
const historyBack = vi.spyOn(window.history, 'back').mockImplementation(() => {})
@@ -1188,6 +1212,7 @@ describe('RoundRelay workbench', () => {
await wrapper.get('.sidebar-settings-entry').trigger('click')
expect(wrapper.findAll('.agent-card')).toHaveLength(AGENTS.length)
expect(wrapper.get('.agent-manager').text()).toContain('OpenCodeReview')
expect(wrapper.get('.system-settings-header h1').text()).toBe('Settings')
expect(wrapper.get('.system-settings-header p').text()).toContain('knowledge bases')
expect(wrapper.get('.sidebar-settings-entry').attributes('aria-current')).toBe('page')
@@ -1204,6 +1229,97 @@ describe('RoundRelay workbench', () => {
wrapper.unmount()
})
it('adds and removes a sanitized Custom Agent through the desktop bridge', async () => {
let customProfile = null
const { wrapper, bridge, state } = await mountApp(({ bridge: configuredBridge, state: configuredState }) => {
configuredBridge.agentInstaller.catalog.mockImplementation(async () => ({
platform: 'darwin',
agents: [
...AGENTS.map(agent => ({
kind: agent.kind,
installed: true,
installSupported: true,
})),
...(customProfile ? [customProfile] : []),
],
}))
configuredBridge.customAgent.create.mockImplementation(async (input) => {
customProfile = {
kind: 'custom-0123456789abcdef',
label: input.label,
description: input.description,
commandName: 'review-agent',
promptMode: input.promptMode,
custom: true,
installed: true,
installSupported: false,
providerMode: 'custom',
imageAttachmentLimit: 0,
version: 'review-agent 1.0.0',
}
configuredState.agents.push({
kind: customProfile.kind,
label: customProfile.label,
description: customProfile.description,
commandName: customProfile.commandName,
promptMode: customProfile.promptMode,
custom: true,
installed: true,
available: true,
credentialState: 'ready',
availabilitySource: 'custom-agent',
showInSidebar: true,
version: customProfile.version,
})
return { canceled: false, agent: structuredClone(customProfile) }
})
configuredBridge.customAgent.delete.mockImplementation(async (kind) => {
customProfile = null
configuredState.agents = configuredState.agents.filter(agent => agent.kind !== kind)
return { deleted: true, kind }
})
})
await wrapper.get('.sidebar-settings-entry').trigger('click')
expect(wrapper.findAll('.agent-catalog-category').map(category => category.get('h2').text()))
.toEqual(['Official Agents', 'Custom Agents'])
await wrapper.get('.manager-toolbar-actions button').trigger('click')
const form = wrapper.get('.custom-agent-form')
await form.get('input[type="text"], input:not([type])').setValue('Repository Reviewer')
const textareas = form.findAll('textarea')
await textareas[0].setValue('Reviews the current repository diff.')
await textareas[1].setValue('review\n--format=text')
await form.find('input[value="argument"]').setValue(true)
expect(form.text()).not.toContain('/private/')
await form.trigger('submit')
await flushPromises()
expect(bridge.customAgent.create).toHaveBeenCalledWith({
label: 'Repository Reviewer',
description: 'Reviews the current repository diff.',
args: ['review', '--format=text'],
promptMode: 'argument',
})
const customCard = wrapper.findAll('.agent-card').find(card => card.text().includes('Repository Reviewer'))
expect(customCard.exists()).toBe(true)
expect(customCard.get('img').attributes('src')).toBe('./agent-logos/custom-agent.svg')
expect(customCard.text()).toContain('Reviews the current repository diff.')
await customCard.get('.agent-card-main').trigger('click')
expect(wrapper.get('.agent-detail-modal').text()).toContain('Managed by the selected CLI')
expect(wrapper.get('.sidebar-visibility-control').text()).toContain('Show in sidebar')
const remove = wrapper.get('.agent-detail-footer .danger-button')
await remove.trigger('click')
expect(remove.text()).toContain('Confirm removal')
await remove.trigger('click')
await flushPromises()
expect(bridge.customAgent.delete).toHaveBeenCalledWith('custom-0123456789abcdef')
expect(wrapper.findAll('.agent-card').some(card => card.text().includes('Repository Reviewer'))).toBe(false)
wrapper.unmount()
})
it('uses the Meldwork wordmark and automatically rotates empty copy in direct and group chats', async () => {
vi.useFakeTimers()
setLocale('zh')
@@ -1392,14 +1508,14 @@ describe('RoundRelay workbench', () => {
createdAt: '2026-07-29T08:00:00Z',
updatedAt: '2026-07-29T08:00:00Z',
})
desktopBridge.localAttachments.pickImages.mockResolvedValueOnce({
desktopBridge.localAttachments.pickAttachments.mockResolvedValueOnce({
attachments: [imageAttachment('preserved-draft')],
})
})
const conversationLink = wrapper.get('.conversation-link')
await conversationLink.trigger('click')
await wrapper.get('[aria-label="Attach images"]').trigger('click')
await wrapper.get('[aria-label="Attach media"]').trigger('click')
await flushPromises()
expect(wrapper.get('.composer-attachment').exists()).toBe(true)
@@ -1435,6 +1551,132 @@ describe('RoundRelay workbench', () => {
wrapper.unmount()
})
it('switches between every group from the collapsed sidebar menu', async () => {
const { wrapper } = await mountApp(({ state }) => {
for (let index = 1; index <= 10; index += 1) {
state.groups.push({
id: `collapsed-group-${index}`,
conversationType: 'group',
name: `Collapsed group ${index}`,
topic: '',
agentKinds: index % 2 ? ['codex', 'hermes'] : ['codex'],
workdir: '/tmp/roundrelay-workspace',
allowWrite: false,
createdAt: `2026-07-29T08:${String(index).padStart(2, '0')}:00Z`,
updatedAt: `2026-07-29T08:${String(index).padStart(2, '0')}:00Z`,
})
}
})
await wrapper.get('.sidebar-toggle').trigger('click')
const switcher = wrapper.get('.collapsed-group-switcher-button')
expect(switcher.attributes('aria-label')).toBe('Switch group chat')
expect(switcher.text()).toContain('10')
await switcher.trigger('click')
await flushPromises()
let menu = document.body.querySelector('.collapsed-group-menu')
expect(menu).not.toBeNull()
expect(menu.querySelectorAll('.collapsed-group-option')).toHaveLength(10)
expect(menu.textContent).toContain('Collapsed group 1')
expect(menu.textContent).toContain('Collapsed group 10')
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await flushPromises()
expect(document.body.querySelector('.collapsed-group-menu')).toBeNull()
expect(document.activeElement).toBe(switcher.element)
await switcher.trigger('click')
await flushPromises()
document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }))
await flushPromises()
expect(document.body.querySelector('.collapsed-group-menu')).toBeNull()
await switcher.trigger('click')
await flushPromises()
menu = document.body.querySelector('.collapsed-group-menu')
const target = [...menu.querySelectorAll('.collapsed-group-option')]
.find(option => option.textContent.includes('Collapsed group 7'))
target.click()
await flushPromises()
expect(document.body.querySelector('.collapsed-group-menu')).toBeNull()
expect(wrapper.get('.conversation-header h1').text()).toBe('Collapsed group 7')
wrapper.unmount()
})
it('uses the Agent running bars for direct and group sessions in the sidebar', async () => {
const { wrapper } = await mountApp(({ state }) => {
state.groups.push(
{
id: 'running-direct',
conversationType: 'direct',
directAgentKind: 'codex',
name: 'Running direct',
topic: '',
agentKinds: ['codex'],
workdir: '/tmp/roundrelay-workspace',
allowWrite: false,
createdAt: '2026-07-29T08:00:00Z',
updatedAt: '2026-07-29T08:00:00Z',
},
{
id: 'running-group',
conversationType: 'group',
name: 'Running group',
topic: '',
agentKinds: ['codex', 'hermes'],
workdir: '/tmp/roundrelay-workspace',
allowWrite: false,
createdAt: '2026-07-29T08:01:00Z',
updatedAt: '2026-07-29T08:01:00Z',
},
)
state.runningGroupIds = ['running-direct', 'running-group']
})
const directBars = wrapper.get('.direct-session-row .run-mark .run-agent-bars')
const groupBars = wrapper.get('.group-conversation-row .run-mark .run-agent-bars')
expect(directBars.findAll('i')).toHaveLength(3)
expect(groupBars.findAll('i')).toHaveLength(3)
expect(wrapper.find('.conversation-nav .run-pulse').exists()).toBe(false)
await wrapper.get('.sidebar-toggle').trigger('click')
await wrapper.get('.collapsed-group-switcher-button').trigger('click')
await flushPromises()
const collapsedGroup = [...document.body.querySelectorAll('.collapsed-group-option')]
.find(option => option.textContent.includes('Running group'))
expect(collapsedGroup.querySelectorAll('.run-agent-bars i')).toHaveLength(3)
wrapper.unmount()
})
it('lists core shortcuts beside conversation settings and handles sidebar toggle', async () => {
const { wrapper } = await mountApp(({ state }) => {
state.groups.push({
id: 'group-shortcuts',
conversationType: 'group',
name: 'Shortcuts',
topic: '',
agentKinds: ['codex', 'hermes'],
workdir: '/tmp/roundrelay-workspace',
allowWrite: false,
createdAt: '2026-07-29T08:00:00Z',
updatedAt: '2026-07-29T08:00:00Z',
})
})
await wrapper.get('.conversation-link').trigger('click')
await wrapper.get('[aria-label="Keyboard shortcuts"]').trigger('click')
expect(wrapper.get('#keyboard-shortcut-menu').attributes('role')).toBe('dialog')
expect(wrapper.findAll('#keyboard-shortcut-menu li')).toHaveLength(5)
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', metaKey: true }))
await flushPromises()
expect(wrapper.get('.app-shell').classes()).toContain('sidebar-collapsed')
expect(wrapper.find('#keyboard-shortcut-menu').exists()).toBe(false)
wrapper.unmount()
})
it('stacks the collapsed brand mark above the expand control without shrinking either', () => {
const source = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8')
@@ -1461,8 +1703,8 @@ describe('RoundRelay workbench', () => {
expect(appSource).toContain(':data-platform="desktopPlatform"')
expect(appSource).toContain("const desktopPlatform = computed(() => api.value?.platform || installCatalog.value.platform || '')")
expect(source).toMatch(/--shell-gradient:\s*linear-gradient\(155deg,\s*#f1f7f5 0%,\s*#e5edef 40%,\s*#d4e2e4 72%,\s*#c4d6db 100%\);/)
expect(source).toMatch(/:root\[data-theme="dark"\]\s*\{[^}]*--shell-gradient:\s*linear-gradient\(155deg,\s*#293334 0%,\s*#22292b 38%,\s*#192125 70%,\s*#11191e 100%\);/s)
expect(source).toMatch(/--shell-gradient:\s*linear-gradient\(145deg,\s*#f1f8f7 0%,\s*#e2efef 48%,\s*#c9dde0 100%\);/)
expect(source).toMatch(/:root\[data-theme="dark"\]\s*\{[^}]*--shell-gradient:\s*linear-gradient\(145deg,\s*#2d3a3b 0%,\s*#222e32 48%,\s*#172226 100%\);/s)
expect(source).toMatch(/--onboarding-frame-gradient:\s*linear-gradient\(145deg,\s*#dcefeb 0%,\s*#9fc2ca 48%,\s*#ddb5aa 100%\);/)
expect(source).toMatch(/:root\[data-theme="dark"\]\s*\{[^}]*--onboarding-frame-gradient:\s*linear-gradient\(145deg,\s*#4a696b 0%,\s*#2e4751 52%,\s*#775047 100%\);/s)
expect(source).toMatch(/\.onboarding-dialog\s*\{[^}]*border:\s*1px solid transparent;[^}]*background:\s*linear-gradient\(var\(--surface\), var\(--surface\)\) padding-box,\s*var\(--onboarding-frame-gradient\) border-box;/s)
@@ -1470,6 +1712,8 @@ describe('RoundRelay workbench', () => {
expect(source).toMatch(/\.app-shell\[data-platform="darwin"\]::before\s*\{[^}]*-webkit-app-region:\s*drag;/s)
expect(source).toMatch(/\.workspace-pane\s*\{[^}]*border-radius:\s*var\(--workspace-radius\);[^}]*background:\s*var\(--workspace-surface\);[^}]*box-shadow:\s*var\(--workspace-shadow\);/s)
expect(source).toMatch(/\.sidebar\s*\{[^}]*background:\s*transparent;/s)
expect(source).not.toContain('--sidebar-gradient')
expect(source).not.toContain('--titlebar-gradient')
expect(source).toMatch(/\.conversation-header\s*\{[^}]*border-bottom:\s*0;[^}]*background:\s*transparent;/s)
expect(source).toMatch(/\.composer-zone\s*\{[^}]*background:\s*transparent;/s)
})
@@ -1557,6 +1801,7 @@ describe('RoundRelay workbench', () => {
expect(source).toMatch(/\.run-status-panel\.solo\s*\{[^}]*background:\s*transparent;[^}]*box-shadow:\s*none;/s)
expect(source).toMatch(/\.run-status-panel\.solo \.run-progress-details\s*\{[^}]*border-top:\s*0;/s)
expect(source).toMatch(/\.run-agent-logo\[data-status="running"\]::before\s*\{[^}]*background:\s*color-mix/s)
expect(source).toMatch(/\.run-status-panel\.group \.run-agent-logo::before\s*\{[^}]*content:\s*none;/s)
expect(source).not.toContain('@keyframes run-agent-halo')
expect(source).toMatch(/\.run-agent-logo\[data-status="queued"\] img,[^{]+\.run-agent-logo\[data-status="not-started"\] img\s*\{[^}]*opacity:\s*0\.38;/s)
expect(source).toMatch(/\.run-trace-panel\s*\{[^}]*border-left:\s*0;/s)
@@ -2301,7 +2546,7 @@ describe('RoundRelay workbench', () => {
})
await wrapper.get('.conversation-link').trigger('click')
await wrapper.get('.conversation-header-actions .icon-button').trigger('click')
await wrapper.findAll('.conversation-header-actions .icon-button').at(-1).trigger('click')
expect(wrapper.get('.modal-header p').text()).toBe('Review')
const primaryInputs = wrapper.findAll('.settings-primary-grid input')
expect(primaryInputs[0].element.value).toBe('Review')
@@ -2412,11 +2657,11 @@ describe('RoundRelay workbench', () => {
wrapper.unmount()
})
it('keeps the inline title editor focused without an outer accent ring', () => {
it('keeps the inline title editor focused without an underline or outer accent ring', () => {
const source = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8')
expect(source).toMatch(/\.inline-title-form input\s*\{[^}]*border:\s*1px solid transparent;[^}]*outline:\s*none;[^}]*box-shadow:\s*inset 0 -1px 0 var\(--border-strong\);/s)
expect(source).toMatch(/\.inline-title-form input:focus-visible\s*\{[^}]*outline:\s*none;[^}]*box-shadow:\s*inset 0 -2px 0 var\(--accent\);/s)
expect(source).toMatch(/\.inline-title-form input\s*\{[^}]*border:\s*1px solid transparent;[^}]*outline:\s*none;[^}]*box-shadow:\s*none;/s)
expect(source).toMatch(/\.inline-title-form input:focus-visible\s*\{[^}]*outline:\s*none;[^}]*box-shadow:\s*none;/s)
expect(source).toMatch(/\.inline-title-form input::selection\s*\{[^}]*var\(--accent\) 28%/s)
})
@@ -2936,6 +3181,44 @@ describe('RoundRelay workbench', () => {
wrapper.unmount()
})
it('mentions a knowledge base to every group Agent when no Agent is specified', async () => {
const { wrapper, bridge } = await mountApp(({ state }) => {
state.groups.push({
id: 'group-knowledge-all',
conversationType: 'group',
name: 'Knowledge review',
topic: '',
agentKinds: ['codex', 'hermes'],
workdir: '/tmp/roundrelay-workspace',
allowWrite: false,
createdAt: '2026-07-29T08:00:00Z',
updatedAt: '2026-07-29T08:00:00Z',
})
})
await wrapper.get('.conversation-link').trigger('click')
const textarea = wrapper.get('.composer-box textarea')
await textarea.setValue('@ding')
await flushPromises()
expect(wrapper.get('.knowledge-base-mention-option').text()).toContain('DingTalk Docs')
await wrapper.get('.knowledge-base-mention-option').trigger('click')
await textarea.setValue('Compare the internal references')
await wrapper.get('.send-button').trigger('click')
await flushPromises()
expect(bridge.localWorkspace.send).toHaveBeenCalledWith({
groupId: 'group-knowledge-all',
text: 'Compare the internal references',
targetKinds: ['codex', 'hermes'],
skillHints: [],
knowledgeBaseHints: [{ kind: 'dingtalk', targetKinds: ['codex', 'hermes'] }],
attachments: [],
mode: 'auto',
maxRounds: 6,
})
wrapper.unmount()
})
it('lists knowledge bases before skills in grouped and direct @ menus', async () => {
const { wrapper } = await mountApp(({ state, bridge: desktopBridge }) => {
state.groups.push({
@@ -3105,7 +3388,7 @@ describe('RoundRelay workbench', () => {
})
await flushPromises()
expect(bridge.localAttachments.importImage).toHaveBeenCalledWith({
expect(bridge.localAttachments.importAttachment).toHaveBeenCalledWith({
name: 'diagram.png',
mimeType: 'image/png',
bytes: Uint8Array.from([1, 2, 3]),
@@ -3190,7 +3473,7 @@ describe('RoundRelay workbench', () => {
wrapper.unmount()
})
it('rejects an oversized pasted image before reading its bytes', async () => {
it('rejects an oversized pasted media file before reading its bytes', async () => {
const arrayBuffer = vi.fn(async () => new ArrayBuffer(0))
const { wrapper, bridge } = await mountApp(({ state }) => {
state.groups.push({
@@ -3215,7 +3498,7 @@ describe('RoundRelay workbench', () => {
getAsFile: () => ({
name: 'large.png',
type: 'image/png',
size: (8 * 1024 * 1024) + 1,
size: (128 * 1024 * 1024) + 1,
arrayBuffer,
}),
}],
@@ -3224,7 +3507,7 @@ describe('RoundRelay workbench', () => {
await flushPromises()
expect(arrayBuffer).not.toHaveBeenCalled()
expect(bridge.localAttachments.importImage).not.toHaveBeenCalled()
expect(bridge.localAttachments.importAttachment).not.toHaveBeenCalled()
expect(wrapper.get('.toast-message').text()).toContain('too large')
wrapper.unmount()
})
@@ -3242,7 +3525,9 @@ describe('RoundRelay workbench', () => {
total = 3
await wrapper.get('.sidebar-settings-entry').trigger('click')
await wrapper.get('.manager-toolbar .secondary-button').trigger('click')
const refresh = wrapper.findAll('.manager-toolbar-actions button')
.find(button => button.text().includes('Refresh catalog'))
await refresh.trigger('click')
await flushPromises()
expect(bridge.agentInstaller.skills.mock.calls.map(([kind]) => kind).sort()).toEqual(['codex', 'hermes'])
@@ -3265,15 +3550,15 @@ describe('RoundRelay workbench', () => {
createdAt: '2026-07-29T08:00:00Z',
updatedAt: '2026-07-29T08:00:00Z',
})
desktopBridge.localAttachments.pickImages.mockResolvedValueOnce({
desktopBridge.localAttachments.pickAttachments.mockResolvedValueOnce({
attachments: [imageAttachment('remove-me')],
})
})
await wrapper.get('.direct-session-open').trigger('click')
await wrapper.get('[aria-label="Attach images"]').trigger('click')
await wrapper.get('[aria-label="Attach media"]').trigger('click')
await flushPromises()
await wrapper.get('[aria-label="Remove image"]').trigger('click')
await wrapper.get('[aria-label="Remove attachment"]').trigger('click')
await flushPromises()
expect(wrapper.find('.composer-attachment').exists()).toBe(false)
@@ -3296,25 +3581,25 @@ describe('RoundRelay workbench', () => {
createdAt: '2026-07-29T08:00:00Z',
updatedAt: '2026-07-29T08:00:00Z',
})
desktopBridge.localAttachments.pickImages
desktopBridge.localAttachments.pickAttachments
.mockResolvedValueOnce({ attachments: firstPick, truncated: false })
.mockResolvedValueOnce({ attachments: secondPick, truncated: true })
})
await wrapper.get('.direct-session-open').trigger('click')
await wrapper.get('[aria-label="Attach images"]').trigger('click')
await wrapper.get('[aria-label="Attach media"]').trigger('click')
await flushPromises()
await wrapper.get('[aria-label="Attach images"]').trigger('click')
await wrapper.get('[aria-label="Attach media"]').trigger('click')
await flushPromises()
expect(wrapper.findAll('.composer-attachment')).toHaveLength(4)
expect(bridge.localAttachments.pickImages.mock.calls).toEqual([[4], [3]])
expect(bridge.localAttachments.pickAttachments.mock.calls).toEqual([[4], [3]])
expect(bridge.localAttachments.discard).not.toHaveBeenCalled()
expect(wrapper.get('.toast-message').text()).toContain('up to 4 images')
expect(wrapper.get('.toast-message').text()).toContain('up to 4 files')
wrapper.unmount()
})
it('limits picked images to the selected Agents minimum capability', async () => {
it('limits picked media to the selected Agent capability', async () => {
const picked = [imageAttachment('hermes-1')]
const { wrapper, bridge } = await mountApp(({ state, bridge: desktopBridge }) => {
state.groups.push({
@@ -3328,20 +3613,20 @@ describe('RoundRelay workbench', () => {
createdAt: '2026-07-29T08:00:00Z',
updatedAt: '2026-07-29T08:00:00Z',
})
desktopBridge.localAttachments.pickImages.mockResolvedValueOnce({
desktopBridge.localAttachments.pickAttachments.mockResolvedValueOnce({
attachments: picked,
truncated: true,
})
})
await wrapper.get('.direct-session-open').trigger('click')
await wrapper.get('[aria-label="Attach images"]').trigger('click')
await wrapper.get('[aria-label="Attach media"]').trigger('click')
await flushPromises()
expect(wrapper.findAll('.composer-attachment')).toHaveLength(1)
expect(bridge.localAttachments.pickImages).toHaveBeenCalledWith(1)
expect(bridge.localAttachments.pickAttachments).toHaveBeenCalledWith(1)
expect(bridge.localAttachments.discard).not.toHaveBeenCalled()
expect(wrapper.get('[aria-label="Attach images"]').attributes()).toHaveProperty('disabled')
expect(wrapper.get('[aria-label="Attach media"]').attributes()).not.toHaveProperty('disabled')
await wrapper.get('.composer-box textarea').trigger('paste', {
clipboardData: {
@@ -3358,12 +3643,12 @@ describe('RoundRelay workbench', () => {
})
await flushPromises()
expect(bridge.localAttachments.importImage).not.toHaveBeenCalled()
expect(wrapper.get('.toast-message').text()).toContain('accepts fewer images')
expect(bridge.localAttachments.importAttachment).not.toHaveBeenCalled()
expect(wrapper.get('.toast-message').text()).toContain('supports fewer files')
wrapper.unmount()
})
it('keeps the attachment action clickable when image support is unavailable', async () => {
it('keeps the attachment action clickable when support is unavailable', async () => {
const { wrapper, bridge } = await mountApp(({ state }) => {
state.agents.push({
kind: 'workbuddy',
@@ -3391,8 +3676,8 @@ describe('RoundRelay workbench', () => {
await attachmentButton.trigger('click')
await flushPromises()
expect(bridge.localAttachments.pickImages).not.toHaveBeenCalled()
expect(wrapper.get('.toast-message').text()).toContain('does not support image attachments')
expect(bridge.localAttachments.pickAttachments).not.toHaveBeenCalled()
expect(wrapper.get('.toast-message').text()).toContain('does not support attachments')
wrapper.unmount()
})
@@ -3422,7 +3707,7 @@ describe('RoundRelay workbench', () => {
updatedAt: '2026-07-29T08:01:00Z',
},
)
desktopBridge.localAttachments.pickImages.mockResolvedValueOnce({
desktopBridge.localAttachments.pickAttachments.mockResolvedValueOnce({
attachments: [imageAttachment('alpha-draft')],
})
})
@@ -3431,7 +3716,7 @@ describe('RoundRelay workbench', () => {
const alphaLink = links.find(link => link.text().includes('Alpha review'))
const betaLink = links.find(link => link.text().includes('Beta review'))
await alphaLink.trigger('click')
await wrapper.get('[aria-label="Attach images"]').trigger('click')
await wrapper.get('[aria-label="Attach media"]').trigger('click')
await flushPromises()
await betaLink.trigger('click')
await flushPromises()
@@ -3530,7 +3815,7 @@ describe('RoundRelay workbench', () => {
createdAt: '2026-07-29T08:00:00Z',
updatedAt: '2026-07-29T08:00:00Z',
})
desktopBridge.localAttachments.importImage.mockReturnValueOnce(pendingImport.promise)
desktopBridge.localAttachments.importAttachment.mockReturnValueOnce(pendingImport.promise)
})
await wrapper.get('.direct-session-open').trigger('click')
@@ -3590,7 +3875,7 @@ describe('RoundRelay workbench', () => {
createdAt: '2026-07-29T08:00:00Z',
updatedAt: '2026-07-29T08:00:00Z',
})
desktopBridge.localAttachments.importImage.mockReturnValueOnce(pendingImport.promise)
desktopBridge.localAttachments.importAttachment.mockReturnValueOnce(pendingImport.promise)
})
await wrapper.get('.direct-session-open').trigger('click')
@@ -3626,9 +3911,9 @@ describe('RoundRelay workbench', () => {
})
await flushPromises()
expect(bridge.localAttachments.importImage).toHaveBeenCalledTimes(1)
expect(bridge.localAttachments.importAttachment).toHaveBeenCalledTimes(1)
expect(secondRead).not.toHaveBeenCalled()
expect(wrapper.get('.toast-message').text()).toContain('current image import')
expect(wrapper.get('.toast-message').text()).toContain('current file import')
pendingImport.resolve(imageAttachment('first'))
await flushPromises()
@@ -3662,7 +3947,7 @@ describe('RoundRelay workbench', () => {
updatedAt: '2026-07-29T08:01:00Z',
},
)
desktopBridge.localAttachments.importImage.mockReturnValueOnce(pendingImport.promise)
desktopBridge.localAttachments.importAttachment.mockReturnValueOnce(pendingImport.promise)
})
const links = wrapper.findAll('.conversation-link')
@@ -4000,7 +4285,7 @@ describe('RoundRelay workbench', () => {
createdAt: '2026-07-29T08:02:00Z',
},
)
desktopBridge.localAttachments.pickImages.mockRejectedValueOnce(new Error('LOCAL_ATTACHMENT_INPUT_INVALID'))
desktopBridge.localAttachments.pickAttachments.mockRejectedValueOnce(new Error('LOCAL_ATTACHMENT_INPUT_INVALID'))
})
await wrapper.get('.direct-session-open').trigger('click')
@@ -4015,13 +4300,13 @@ describe('RoundRelay workbench', () => {
expect(wrapper.find('#message-plan-warning').exists()).toBe(false)
expect(wrapper.findAll('.message-dismiss-button')).toHaveLength(0)
await wrapper.get('[aria-label="Attach images"]').trigger('click')
await wrapper.get('[aria-label="Attach media"]').trigger('click')
await flushPromises()
expect(wrapper.get('.toast-message').attributes('role')).toBe('status')
expect(wrapper.get('.toast-dismiss-button').attributes('aria-label')).toBe('Dismiss')
await wrapper.get('.toast-dismiss-button').trigger('click')
expect(wrapper.find('.toast-message').exists()).toBe(false)
expect(bridge.localAttachments.pickImages).toHaveBeenCalledTimes(1)
expect(bridge.localAttachments.pickAttachments).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
@@ -4246,6 +4531,7 @@ describe('RoundRelay workbench', () => {
expect(writeText).toHaveBeenCalledWith('[Open docs](https://example.com)\n\nCopy this answer.')
expect(wrapper.get('.message-row.agent').classes()).toContain('copied')
expect(wrapper.get('.message-row.agent .message-copy-button').attributes('aria-label')).toBe('Copied')
expect(wrapper.get('.copy-toast-message').text()).toBe('Copied to clipboard')
writeText.mockClear()
await wrapper.get('.message-row.user .message-copy-button').trigger('keydown', { key: 'Enter' })
@@ -4262,6 +4548,161 @@ describe('RoundRelay workbench', () => {
wrapper.unmount()
})
it('requires confirmation before persistently deleting replies and whole topics', async () => {
const { wrapper, state, bridge } = await mountApp(({ state: nextState, bridge: nextBridge }) => {
nextState.groups.push({
id: 'group-1',
conversationType: 'group',
name: 'Cleanup review',
topic: '',
agentKinds: ['codex', 'hermes'],
workdir: '/tmp/roundrelay-workspace',
allowWrite: false,
createdAt: '2026-07-29T08:00:00Z',
updatedAt: '2026-07-29T08:00:00Z',
})
nextState.messages.push(
{
id: 'keep-root',
groupId: 'group-1',
role: 'user',
content: 'Keep this topic',
createdAt: '2026-07-29T08:00:30Z',
},
{
id: 'root-1',
groupId: 'group-1',
role: 'user',
content: 'Delete this topic',
targetKinds: ['codex', 'hermes'],
createdAt: '2026-07-29T08:01:00Z',
},
{
id: 'reply-1',
groupId: 'group-1',
role: 'agent',
agentKind: 'codex',
content: 'Delete with the topic',
threadRootId: 'root-1',
createdAt: '2026-07-29T08:02:00Z',
trace: {
runId: 'run-delete',
agentRunId: 'agent-run-delete',
status: 'completed',
sourceMessageIds: ['root-1'],
events: [{ type: 'tool_end', status: 'completed', title: 'Review complete' }],
},
},
{
id: 'reply-2',
groupId: 'group-1',
role: 'agent',
agentKind: 'hermes',
content: 'Delete only this reply first',
threadRootId: 'root-1',
createdAt: '2026-07-29T08:03:00Z',
},
)
nextBridge.localWorkspace.deleteMessage.mockImplementation(async (groupId, messageId) => {
const target = nextState.messages.find(message => (
message.groupId === groupId && message.id === messageId
))
const deletedIds = new Set([messageId])
if (target?.role === 'user' && !target.threadRootId) {
for (const message of nextState.messages) {
if (message.groupId === groupId && message.threadRootId === messageId) {
deletedIds.add(message.id)
}
}
}
nextState.messages = nextState.messages.filter(message => !deletedIds.has(message.id))
return structuredClone(nextState)
})
})
const writeText = navigator.clipboard.writeText
await wrapper.get('.conversation-link').trigger('click')
await flushPromises()
expect(wrapper.find('.run-status-panel.history').exists()).toBe(true)
const rootDelete = () => wrapper.get('#message-root-1 .message-delete-button')
await rootDelete().trigger('click')
expect(bridge.localWorkspace.deleteMessage).not.toHaveBeenCalled()
expect(rootDelete().classes()).toContain('armed')
expect(rootDelete().attributes('aria-label')).toBe('Click again to delete this topic and its replies')
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await flushPromises()
expect(rootDelete().classes()).not.toContain('armed')
expect(rootDelete().attributes('aria-label')).toBe('Delete topic')
await rootDelete().trigger('click')
const replyDelete = wrapper.get('#message-reply-2 .message-delete-button')
await replyDelete.trigger('click')
expect(rootDelete().classes()).not.toContain('armed')
expect(replyDelete.classes()).toContain('armed')
await replyDelete.trigger('click')
await flushPromises()
expect(bridge.localWorkspace.deleteMessage).toHaveBeenLastCalledWith('group-1', 'reply-2')
expect(wrapper.find('#message-reply-2').exists()).toBe(false)
expect(wrapper.find('#message-root-1').exists()).toBe(true)
expect(writeText).not.toHaveBeenCalled()
await rootDelete().trigger('click')
await rootDelete().trigger('click')
await flushPromises()
expect(bridge.localWorkspace.deleteMessage).toHaveBeenLastCalledWith('group-1', 'root-1')
expect(state.messages.map(message => message.id)).toEqual(['keep-root'])
expect(wrapper.find('#message-root-1').exists()).toBe(false)
expect(wrapper.find('#message-reply-1').exists()).toBe(false)
expect(wrapper.find('#message-keep-root').exists()).toBe(true)
expect(wrapper.find('.run-status-panel.history').exists()).toBe(false)
wrapper.unmount()
})
it('disables message deletion while the conversation is running', async () => {
const { wrapper, bridge } = await mountApp(({ state }) => {
state.groups.push({
id: 'group-running',
conversationType: 'group',
name: 'Running cleanup',
topic: '',
agentKinds: ['codex'],
workdir: '/tmp/roundrelay-workspace',
allowWrite: false,
createdAt: '2026-07-29T08:00:00Z',
updatedAt: '2026-07-29T08:00:00Z',
})
state.messages.push({
id: 'running-root',
groupId: 'group-running',
role: 'user',
content: 'Do not delete while running',
createdAt: '2026-07-29T08:01:00Z',
})
state.runningGroupIds = ['group-running']
state.runs = [{
groupId: 'group-running',
phase: 'running',
mode: 'manual',
targetKinds: ['codex'],
currentKind: 'codex',
threadRootId: 'running-root',
progress: [],
}]
})
await wrapper.get('.conversation-link').trigger('click')
await flushPromises()
const deleteButton = wrapper.get('.message-delete-button')
expect(deleteButton.attributes()).toHaveProperty('disabled')
expect(deleteButton.attributes('aria-label')).toBe('Stop the current run before deleting messages')
await deleteButton.trigger('click')
expect(bridge.localWorkspace.deleteMessage).not.toHaveBeenCalled()
wrapper.unmount()
})
it('reopens and locates the declared running topic while keeping progress and execution metadata collapsed', async () => {
const scrollIntoView = vi.fn()
HTMLElement.prototype.scrollIntoView = scrollIntoView
@@ -116,7 +116,14 @@ describe('run event normalization', () => {
seq: 99,
detail: 'Output: 5 lines, 47 bytes',
},
{ evidenceId: 'E-R2-CODEX-02', type: 'raw_stdout', status: 'completed' },
{
evidenceId: 'E-R2-CODEX-02',
type: 'tool_start',
status: 'partial',
title: 'read_file',
summary: 'read: docs/architecture.md',
},
{ evidenceId: 'E-R2-CODEX-03', type: 'raw_stdout', status: 'completed' },
],
sourceMessageIds: ['root-1'],
truncated: true,
@@ -132,13 +139,22 @@ describe('run event normalization', () => {
truncated: true,
context: { includedCount: 3, omittedCount: 7, charCount: 1200, sessionRotated: true },
})
expect(trace.events).toEqual([{
evidenceId: 'E-R2-CODEX-01',
type: 'reasoning_summary',
status: 'completed',
summary: 'Compare both options',
detail: 'Output: 5 lines, 47 bytes',
}])
expect(trace.events).toEqual([
{
evidenceId: 'E-R2-CODEX-01',
type: 'reasoning_summary',
status: 'completed',
summary: 'Compare both options',
detail: 'Output: 5 lines, 47 bytes',
},
{
evidenceId: 'E-R2-CODEX-02',
type: 'tool_start',
status: 'partial',
title: 'read_file',
summary: 'read: docs/architecture.md',
},
])
expect(trace.context).not.toHaveProperty('ignored')
const snapshot = normalizeSnapshot({
+26 -1
View File
@@ -19,13 +19,38 @@ export const AGENTS = Object.freeze([
{ kind: 'claude', label: 'Claude Code', logo: publicAsset('agent-logos/claude.png'), providerMode: 'anthropic', imageLimit: 0 },
{ kind: 'gemini', label: 'Gemini CLI', logo: publicAsset('agent-logos/gemini.svg'), providerMode: 'native', imageLimit: 0 },
{ kind: 'opencode', label: 'OpenCode', logo: publicAsset('agent-logos/opencode.svg'), providerMode: 'native', imageLimit: 4 },
{ kind: 'opencodereview', label: 'OpenCodeReview', logo: publicAsset('agent-logos/opencodereview.svg'), providerMode: 'compatible', imageLimit: 0 },
{ kind: 'qwen', label: 'Qwen Code', logo: publicAsset('agent-logos/qwen.svg'), providerMode: 'compatible', imageLimit: 0 },
])
const BY_KIND = new Map(AGENTS.map(agent => [agent.kind, agent]))
const CUSTOM_BY_KIND = new Map()
const CUSTOM_AGENT_KIND = /^custom-[a-f0-9]{16}$/
const CUSTOM_AGENT_LOGO = publicAsset('agent-logos/custom-agent.svg')
export function setCustomAgentProfiles(profiles = []) {
CUSTOM_BY_KIND.clear()
for (const profile of Array.isArray(profiles) ? profiles : []) {
const kind = String(profile?.kind || '')
const label = String(profile?.label || '').trim().slice(0, 60)
if (!CUSTOM_AGENT_KIND.test(kind) || !label || profile?.custom !== true) continue
CUSTOM_BY_KIND.set(kind, {
kind,
label,
logo: CUSTOM_AGENT_LOGO,
providerMode: 'custom',
imageLimit: 0,
custom: true,
description: String(profile?.description || '').trim().slice(0, 240),
commandName: String(profile?.commandName || '').trim().slice(0, 160),
promptMode: ['stdin', 'argument'].includes(profile?.promptMode) ? profile.promptMode : 'stdin',
})
}
}
function agentProfile(kind) {
return BY_KIND.get(kind) || { kind, label: kind || 'Agent', logo: '', providerMode: 'native' }
return BY_KIND.get(kind) || CUSTOM_BY_KIND.get(kind)
|| { kind, label: kind || 'Agent', logo: CUSTOM_AGENT_LOGO, providerMode: 'native' }
}
export function agentLabel(kind) {
+2
View File
@@ -17,6 +17,8 @@ const RUN_EVENT_TYPES = new Set([
const CAPSULE_EVENT_TYPES = new Set([
'reasoning_summary',
'plan',
'tool_start',
'tool_update',
'tool_result_summary',
'warning',
])
+172 -8
View File
@@ -31,6 +31,8 @@ const messages = {
'nav.direct': 'Direct chats',
'nav.sidebarAgents': 'Agents',
'nav.groups': 'Groups',
'nav.switchGroup': 'Switch group chat',
'nav.groupCount': '{count} local group chats',
'nav.agents': 'Local Agents',
'nav.provider': 'Provider',
'nav.settings': 'Settings',
@@ -103,11 +105,13 @@ const messages = {
'agent.providerShort.responses': 'Responses API',
'agent.providerShort.anthropic': 'Anthropic compatible',
'agent.providerShort.native': 'Native Provider',
'agent.providerShort.custom': 'CLI managed',
'agent.provider.compatible': 'Supports an individual OpenAI-compatible Provider',
'agent.provider.experimental': 'Individual external Provider support is experimental',
'agent.provider.responses': 'Uses a Responses API-compatible Provider',
'agent.provider.anthropic': 'Uses an Anthropic-compatible Provider',
'agent.provider.native': 'Uses its own local Provider settings',
'agent.provider.custom': 'Uses the selected local CLI configuration',
'agent.description.codex': 'Structured reasoning, dependable tool use, implementation, and verifiable task execution.',
'agent.description.hermes': 'Flexible open-source reasoning for research, planning, and long-running tasks.',
'agent.description.openclaw': 'Autonomous local workflows with persistent execution and managed task context.',
@@ -118,6 +122,7 @@ const messages = {
'agent.description.gemini': 'Broad context, multimodal input, and cross-format workflows from the Google ecosystem.',
'agent.description.opencode': 'Transparent, permission-aware terminal execution with flexible model support.',
'agent.description.qwen': 'Open-source bilingual reasoning and task execution with compatible model options.',
'agent.description.opencodereview': 'Diff-focused AI code review with deterministic file selection and line-level findings.',
'agent.soul.codex': 'Codex is the exacting execution partner: it reads the available context, plans narrowly, uses tools carefully, and turns requirements into verifiable work.',
'agent.soul.hermes': 'Hermes is the patient strategist: it is useful for open-ended research, decomposition, and long reasoning chains that benefit from a local-first workflow.',
'agent.soul.openclaw': 'OpenClaw is the autonomous operator: it is best when a task needs persistent execution, explicit workspace context, and repeatable local workflows.',
@@ -128,6 +133,7 @@ const messages = {
'agent.soul.gemini': 'Gemini CLI is the broad-context collaborator: it works well for Google ecosystem tasks and multimodal workflows.',
'agent.soul.opencode': 'OpenCode is the transparent terminal agent: it emphasizes permission-aware execution and flexible model routing.',
'agent.soul.qwen': 'Qwen Code is the bilingual open model builder: it is strong for Chinese-English tasks and compatible local model setups.',
'agent.soul.opencodereview': 'OpenCodeReview is the focused reviewer: it reads the active git diff and returns targeted code review findings.',
'conversation.members': '{count} Agents',
'conversation.direct': 'Direct chat',
'conversation.directDefaultName': '{agent} chat {count}',
@@ -168,7 +174,15 @@ const messages = {
'conversation.attachmentTurn': 'Attachment message',
'conversation.copyMessage': 'Copy message',
'conversation.copied': 'Copied',
'conversation.copySuccess': 'Copied to clipboard',
'conversation.copyFailed': 'The message could not be copied.',
'conversation.deleteMessage': 'Delete message',
'conversation.confirmDeleteMessage': 'Click again to delete this message',
'conversation.deleteTopic': 'Delete topic',
'conversation.confirmDeleteTopic': 'Click again to delete this topic and its replies',
'conversation.deleteTurn': 'Delete turn',
'conversation.confirmDeleteTurn': 'Click again to delete this turn and its replies',
'conversation.deleteMessageRunning': 'Stop the current run before deleting messages',
'run.current': 'Current',
'run.queued': 'Queued',
'run.completed': 'Completed',
@@ -282,11 +296,15 @@ const messages = {
'composer.knowledgeBaseDescription.dingtalk': 'Search DingTalk documents through the configured dws connection',
'composer.knowledgeBaseDescription.obsidian': 'Use notes from your selected local vault as task context',
'composer.attachImages': 'Attach images',
'composer.removeAttachment': 'Remove image',
'composer.attachmentLimit': 'You can attach up to 4 images.',
'composer.attachmentImporting': 'Wait for the current image import to finish.',
'composer.attachmentsUnavailable': 'Image attachments are unavailable in this window.',
'composer.attachMedia': 'Attach media',
'composer.removeAttachment': 'Remove attachment',
'composer.attachmentLimit': 'You can attach up to 4 files.',
'composer.attachmentTypeLimit': 'This Agent supports fewer files of this type.',
'composer.attachmentImporting': 'Wait for the current file import to finish.',
'composer.attachmentsUnavailable': 'Attachments are unavailable in this window.',
'composer.attachmentsUnsupported': 'The selected Agent does not support attachments.',
'composer.pastedImage': 'Pasted image',
'composer.pastedAttachment': 'Pasted attachment',
'group.newTitle': 'New Agent group',
'group.name': 'Name',
'group.namePlaceholder': 'Design review',
@@ -318,6 +336,35 @@ const messages = {
'systemSettings.providerForAgent': 'Provider',
'systemSettings.openAgentDetail': 'View {agent} details',
'systemSettings.openAgentDetailDefault': 'Agent details',
'systemSettings.officialAgents': 'Official Agents',
'systemSettings.officialAgentsHint': 'Built-in CLI connections maintained by Meldwork.',
'systemSettings.customAgents': 'Custom Agents',
'systemSettings.customAgentsHint': 'Local CLI connections selected and managed on this device.',
'systemSettings.agentCount': '{count} Agents',
'customAgent.add': 'Add custom Agent',
'customAgent.title': 'Add custom Agent',
'customAgent.subtitle': 'Connect a CLI that can accept a task through stdin or one final argument.',
'customAgent.name': 'Agent name',
'customAgent.namePlaceholder': 'Repository reviewer',
'customAgent.nameRequired': 'Enter an Agent name.',
'customAgent.description': 'Description',
'customAgent.descriptionPlaceholder': 'What this CLI is best at',
'customAgent.arguments': 'Fixed arguments',
'customAgent.argumentsPlaceholder': 'review\n--format=text',
'customAgent.argumentsHint': 'One literal argument per line. Shell syntax and secret-like arguments are rejected.',
'customAgent.promptMode': 'Task delivery',
'customAgent.promptStdin': 'Standard input',
'customAgent.promptArgument': 'Final argument',
'customAgent.securityNotice': 'Meldwork runs the selected executable without a shell and with a reduced environment. The CLI still runs under your OS account and follows its own permissions for the selected working directory.',
'customAgent.chooseExecutable': 'Choose executable and add',
'customAgent.defaultDescription': 'A locally connected command-line Agent.',
'customAgent.detailBody': 'This Agent is executed through a user-selected local CLI with fixed arguments and bounded output.',
'customAgent.cliManaged': 'Managed by the selected CLI',
'customAgent.skillsUnsupported': 'Local skills are not supported',
'customAgent.executableUnavailable': 'Executable unavailable',
'customAgent.delete': 'Remove Agent',
'customAgent.deleteConfirm': 'Confirm removal',
'customAgent.empty': 'No custom Agents have been added.',
'knowledgeBase.title': 'Knowledge bases',
'knowledgeBase.readyCount': '{ready} ready, {total} total',
'knowledgeBase.subtitle': 'Connect local document sources and verify the access available to Meldwork.',
@@ -422,6 +469,14 @@ const messages = {
'agentDetail.images': 'Images',
'agentDetail.soul': 'Soul',
'agentDetail.localSkills': 'Local skills',
'agentDetail.showInSidebar': 'Show in sidebar',
'agentDetail.showInSidebarHint': 'Keep this ready Agent in the left navigation.',
'shortcut.title': 'Keyboard shortcuts',
'shortcut.toggleSidebar': 'Toggle sidebar',
'shortcut.newGroup': 'Create group chat',
'shortcut.previousConversation': 'Previous conversation',
'shortcut.nextConversation': 'Next conversation',
'shortcut.openAgents': 'Open Agent settings',
'installer.title': 'Manage local Agents',
'installer.subtitle': 'Detection and installation run on this computer.',
'installer.refresh': 'Refresh catalog',
@@ -492,6 +547,7 @@ const messages = {
'provider.docs.gemini': 'Gemini CLI uses Google AI credentials. Meldwork maps the saved key into Gemini-compatible runtime variables.',
'provider.docs.opencode': 'OpenCode supports multiple provider backends. Meldwork keeps the selected OpenAI/OpenRouter/custom values scoped to OpenCode.',
'provider.docs.qwen': 'Qwen Code can run with its official DashScope-compatible endpoint, OpenRouter, or another OpenAI-compatible Provider.',
'provider.docs.opencodereview': 'OpenCodeReview receives an OpenAI-compatible chat completions URL, token, and model only for this Agent run.',
'provider.docs.custom': 'This Agent uses the saved Provider name, base URL, model, and encrypted key for local execution when supported.',
'provider.configured': 'Configured',
'provider.nativeReady': 'Native configuration active',
@@ -562,6 +618,7 @@ const messages = {
'error.providerMetadata': 'Check the Provider name, URL, and model.',
'error.providerRequired': 'Configure this Agent\'s Provider before running it.',
'error.groupRunning': 'Stop the current Agent run first.',
'error.messageNotFound': 'This message no longer exists.',
'error.messageRequired': 'Enter a message.',
'error.messageTargetRequired': 'Select at least one Agent.',
'error.agentUnavailable': 'This local Agent is unavailable.',
@@ -575,12 +632,28 @@ const messages = {
'error.agentKindUnsupported': 'This local Agent type is not supported.',
'error.agentAuthRequired': 'Sign in to this Agent or configure its credentials, then try again.',
'error.agentProcessFailed': 'The Agent process failed before completing the task.',
'error.customAgentLimit': 'You can connect up to 32 custom Agents.',
'error.customAgentLabel': 'Enter a valid Agent name.',
'error.customAgentDescription': 'Keep the description within 240 characters.',
'error.customAgentArguments': 'Use no more than 24 fixed arguments, one per line.',
'error.customAgentSecret': 'Credentials and secret-like values cannot be stored in fixed arguments.',
'error.customAgentPromptMode': 'Choose standard input or final argument delivery.',
'error.customAgentExecutable': 'Choose an executable file that is available on this computer.',
'error.customAgentExecutableUnsupported': 'This executable type cannot be launched without a shell.',
'error.customAgentUnavailable': 'This custom Agent no longer exists.',
'error.customAgentInUse': 'Delete the conversations that use this Agent before removing it.',
'error.customAgentPrompt': 'The task is too large for this custom Agent connection.',
'error.customAgentOutputLimit': 'The custom Agent produced too much output and was stopped.',
'error.customAgentSpawn': 'The custom Agent executable could not be started.',
'error.customAgentProcess': 'The custom Agent process exited before completing the task.',
'error.agentExited': 'The Agent process exited before completing the task.',
'error.agentEmptyResponse': 'The Agent completed without returning a message.',
'error.agentUnknownFailure': 'The Agent failed without returning diagnostic details.',
'error.agentSpawnFailed': 'The Agent process could not be started.',
'error.imageUnsupported': 'A selected Agent does not support image attachments.',
'error.imageLimit': 'A selected Agent accepts fewer images. Remove some attachments and try again.',
'error.mediaUnsupported': 'A selected Agent does not support this media type.',
'error.mediaLimit': 'A selected Agent supports fewer files of this media type.',
'error.attachmentInvalid': 'This image attachment is no longer valid. Remove it and attach it again.',
'error.attachmentLimit': 'You can attach up to 4 images.',
'error.attachmentType': 'Only supported image files can be attached.',
@@ -620,6 +693,8 @@ const messages = {
'nav.direct': '单聊',
'nav.sidebarAgents': 'Agent',
'nav.groups': '群聊',
'nav.switchGroup': '快速切换群聊',
'nav.groupCount': '{count} 个本地群聊',
'nav.agents': '本地 Agent',
'nav.provider': 'Provider',
'nav.settings': '设置',
@@ -692,11 +767,13 @@ const messages = {
'agent.providerShort.responses': 'Responses API',
'agent.providerShort.anthropic': 'Anthropic 兼容',
'agent.providerShort.native': '本机 Provider',
'agent.providerShort.custom': '由 CLI 管理',
'agent.provider.compatible': '可单独配置 OpenAI 兼容 Provider',
'agent.provider.experimental': '独立外部 Provider 为实验性支持',
'agent.provider.responses': '使用兼容 Responses API 的 Provider',
'agent.provider.anthropic': '使用 Anthropic 兼容 Provider',
'agent.provider.native': '沿用本机已有 Provider 配置',
'agent.provider.custom': '使用所选本地 CLI 的配置',
'agent.description.codex': '适合结构化推理、可靠工具调用、实现工作与可验证的任务执行。',
'agent.description.hermes': '适合开源推理、调研规划与持续时间较长的复杂任务。',
'agent.description.openclaw': '适合自主本地工作流、持久执行与任务上下文管理。',
@@ -707,6 +784,7 @@ const messages = {
'agent.description.gemini': '具备广泛上下文与多模态能力,适合 Google 生态和跨格式工作流。',
'agent.description.opencode': '强调透明权限控制,适合灵活模型驱动的终端执行。',
'agent.description.qwen': '开源且双语能力突出,适合兼容模型下的推理与任务执行。',
'agent.description.opencodereview': '面向 Git diff 的 AI 代码审查,强调确定性文件选择和行级问题定位。',
'agent.soul.codex': 'Codex 是精确的执行伙伴:它会阅读已有上下文、收敛范围、谨慎用工具,把需求落实成可验证的工作。',
'agent.soul.hermes': 'Hermes 是耐心的策略型助手:适合开放式调研、任务拆解,以及受益于本地工作流的长链路推理。',
'agent.soul.openclaw': 'OpenClaw 是自治执行者:当任务需要持续执行、明确工作区上下文和可重复的本地流程时最合适。',
@@ -717,6 +795,7 @@ const messages = {
'agent.soul.gemini': 'Gemini CLI 是广上下文协作者:适合 Google 生态任务和多模态工作流。',
'agent.soul.opencode': 'OpenCode 是透明的终端型 Agent:强调权限可见、执行可控和灵活的模型路由。',
'agent.soul.qwen': 'Qwen Code 是双语开源构建者:适合中英文任务和兼容的本地模型配置。',
'agent.soul.opencodereview': 'OpenCodeReview 是聚焦的审查者:读取当前 Git diff,并输出针对性的代码审查意见。',
'conversation.members': '{count} 个 Agent',
'conversation.direct': '单聊',
'conversation.directDefaultName': '{agent} 会话 {count}',
@@ -757,7 +836,15 @@ const messages = {
'conversation.attachmentTurn': '附件消息',
'conversation.copyMessage': '复制消息',
'conversation.copied': '已复制',
'conversation.copySuccess': '已复制到剪贴板',
'conversation.copyFailed': '无法复制该消息。',
'conversation.deleteMessage': '删除消息',
'conversation.confirmDeleteMessage': '再次单击以删除该消息',
'conversation.deleteTopic': '删除话题',
'conversation.confirmDeleteTopic': '再次单击以删除该话题及其回复',
'conversation.deleteTurn': '删除本轮对话',
'conversation.confirmDeleteTurn': '再次单击以删除本轮对话及其回复',
'conversation.deleteMessageRunning': '请先停止当前运行,再删除消息',
'run.current': '当前',
'run.queued': '排队',
'run.completed': '完成',
@@ -871,11 +958,15 @@ const messages = {
'composer.knowledgeBaseDescription.dingtalk': '通过已配置的 dws 连接查找钉钉文档',
'composer.knowledgeBaseDescription.obsidian': '把已选择的本地仓库作为这次任务的上下文',
'composer.attachImages': '添加图片',
'composer.removeAttachment': '移除图片',
'composer.attachmentLimit': '最多添加 4 张图片。',
'composer.attachmentImporting': '请等待当前图片导入完成。',
'composer.attachmentsUnavailable': '当前窗口无法使用图片附件。',
'composer.attachMedia': '添加媒体',
'composer.removeAttachment': '移除附件',
'composer.attachmentLimit': '最多添加 4 个文件。',
'composer.attachmentTypeLimit': '当前 Agent 对此文件类型的数量限制更低。',
'composer.attachmentImporting': '请等待当前文件导入完成。',
'composer.attachmentsUnavailable': '当前窗口无法使用附件。',
'composer.attachmentsUnsupported': '所选 Agent 不支持附件。',
'composer.pastedImage': '粘贴的图片',
'composer.pastedAttachment': '粘贴的附件',
'group.newTitle': '新建 Agent 群聊',
'group.name': '名称',
'group.namePlaceholder': '方案评审',
@@ -907,6 +998,35 @@ const messages = {
'systemSettings.providerForAgent': 'Provider',
'systemSettings.openAgentDetail': '查看 {agent} 详情',
'systemSettings.openAgentDetailDefault': 'Agent 详情',
'systemSettings.officialAgents': '官方 Agent',
'systemSettings.officialAgentsHint': '由 Meldwork 维护的内置 CLI 连接。',
'systemSettings.customAgents': '自定义 Agent',
'systemSettings.customAgentsHint': '在本机选择并管理的 CLI 连接。',
'systemSettings.agentCount': '{count} 个 Agent',
'customAgent.add': '添加自定义 Agent',
'customAgent.title': '添加自定义 Agent',
'customAgent.subtitle': '接入能够通过标准输入或最后一个参数接收任务的 CLI。',
'customAgent.name': 'Agent 名称',
'customAgent.namePlaceholder': '代码仓库审查 Agent',
'customAgent.nameRequired': '请输入 Agent 名称。',
'customAgent.description': '能力描述',
'customAgent.descriptionPlaceholder': '这个 CLI 最擅长处理什么任务',
'customAgent.arguments': '固定参数',
'customAgent.argumentsPlaceholder': 'review\n--format=text',
'customAgent.argumentsHint': '每行一个字面参数;不解析 Shell 语法,也不允许保存疑似密钥的参数。',
'customAgent.promptMode': '任务传递方式',
'customAgent.promptStdin': '标准输入',
'customAgent.promptArgument': '最后一个参数',
'customAgent.securityNotice': 'Meldwork 会在精简环境中直接运行所选可执行文件,不经过 Shell。该 CLI 仍以你的系统账户运行,并按自身权限访问所选工作目录。',
'customAgent.chooseExecutable': '选择可执行文件并添加',
'customAgent.defaultDescription': '通过本地命令行接入的自定义 Agent。',
'customAgent.detailBody': '该 Agent 通过用户选择的本地 CLI、固定参数和受限输出执行任务。',
'customAgent.cliManaged': '由所选 CLI 自行管理',
'customAgent.skillsUnsupported': '暂不支持本地技能',
'customAgent.executableUnavailable': '可执行文件不可用',
'customAgent.delete': '移除 Agent',
'customAgent.deleteConfirm': '确认移除',
'customAgent.empty': '尚未添加自定义 Agent。',
'knowledgeBase.title': '知识库',
'knowledgeBase.readyCount': '已就绪 {ready} / 共 {total}',
'knowledgeBase.subtitle': '连接本地文档源,并确认 Meldwork 当前具备的访问能力。',
@@ -1011,6 +1131,14 @@ const messages = {
'agentDetail.images': '图片',
'agentDetail.soul': '角色',
'agentDetail.localSkills': '本地技能',
'agentDetail.showInSidebar': '在侧边栏显示',
'agentDetail.showInSidebarHint': '将此可用 Agent 保留在左侧导航中。',
'shortcut.title': '快捷键',
'shortcut.toggleSidebar': '收起或展开侧边栏',
'shortcut.newGroup': '新建群聊',
'shortcut.previousConversation': '上一个会话',
'shortcut.nextConversation': '下一个会话',
'shortcut.openAgents': '打开 Agent 设置',
'installer.title': '管理本地 Agent',
'installer.subtitle': '检测和安装均在这台电脑上进行。',
'installer.refresh': '刷新列表',
@@ -1081,6 +1209,7 @@ const messages = {
'provider.docs.gemini': 'Gemini CLI 使用 Google AI 凭据。Meldwork 会把保存的 Key 映射到 Gemini 兼容运行时变量。',
'provider.docs.opencode': 'OpenCode 支持多种 provider 后端。Meldwork 会把 OpenAI、OpenRouter 或自定义配置限定在 OpenCode 中使用。',
'provider.docs.qwen': 'Qwen Code 可使用官方 DashScope 兼容端点、OpenRouter 或其他 OpenAI 兼容 Provider。',
'provider.docs.opencodereview': 'OpenCodeReview 会在本 Agent 运行时接收 OpenAI 兼容 chat completions URL、Token 和模型。',
'provider.docs.custom': '该 Agent 会在支持时使用保存的 Provider 名称、Base URL、模型和加密 Key 进行本地运行。',
'provider.configured': '已配置',
'provider.nativeReady': '原生配置生效中',
@@ -1151,6 +1280,7 @@ const messages = {
'error.providerMetadata': '请检查 Provider 名称、地址和模型。',
'error.providerRequired': '请先配置该 Agent 的独立 Provider,再运行。',
'error.groupRunning': '请先停止当前 Agent 运行。',
'error.messageNotFound': '该消息已不存在。',
'error.messageRequired': '请输入消息。',
'error.messageTargetRequired': '请至少选择一个 Agent。',
'error.agentUnavailable': '该本地 Agent 当前不可用。',
@@ -1164,12 +1294,28 @@ const messages = {
'error.agentKindUnsupported': '当前不支持该本地 Agent 类型。',
'error.agentAuthRequired': '请先登录该 Agent 或完成凭据配置后再试。',
'error.agentProcessFailed': 'Agent 进程在任务完成前执行失败。',
'error.customAgentLimit': '最多可接入 32 个自定义 Agent。',
'error.customAgentLabel': '请输入有效的 Agent 名称。',
'error.customAgentDescription': '能力描述不能超过 240 个字符。',
'error.customAgentArguments': '固定参数最多 24 个,请确保每行只有一个参数。',
'error.customAgentSecret': '固定参数中不能保存凭证或疑似密钥内容。',
'error.customAgentPromptMode': '请选择标准输入或最后一个参数作为任务传递方式。',
'error.customAgentExecutable': '请选择当前电脑上可执行的文件。',
'error.customAgentExecutableUnsupported': '此文件类型无法在不经过 Shell 的情况下启动。',
'error.customAgentUnavailable': '这个自定义 Agent 已不存在。',
'error.customAgentInUse': '请先删除仍在使用这个 Agent 的会话。',
'error.customAgentPrompt': '任务内容过大,无法通过该自定义 Agent 连接传递。',
'error.customAgentOutputLimit': '自定义 Agent 输出过多,运行已停止。',
'error.customAgentSpawn': '无法启动自定义 Agent 的可执行文件。',
'error.customAgentProcess': '自定义 Agent 进程未完成任务便已退出。',
'error.agentExited': 'Agent 进程在任务完成前退出。',
'error.agentEmptyResponse': 'Agent 已结束运行,但没有返回消息。',
'error.agentUnknownFailure': 'Agent 调用失败,且没有返回诊断信息。',
'error.agentSpawnFailed': '无法启动 Agent 进程。',
'error.imageUnsupported': '所选 Agent 中有不支持图片附件的 Agent。',
'error.imageLimit': '所选 Agent 支持的图片数量更少,请移除部分图片后重试。',
'error.mediaUnsupported': '所选 Agent 不支持此媒体类型。',
'error.mediaLimit': '所选 Agent 支持的此媒体类型文件数量更少。',
'error.attachmentInvalid': '该图片附件已失效,请移除后重新添加。',
'error.attachmentLimit': '最多添加 4 张图片。',
'error.attachmentType': '只能添加受支持的图片文件。',
@@ -1249,6 +1395,7 @@ export const DESKTOP_ERROR_MESSAGE_KEYS = Object.freeze({
PROVIDER_STORAGE_PATH_REQUIRED: 'error.generic',
OPENCLAW_PROVIDER_INVALID: 'error.providerMetadata',
LOCAL_GROUP_RUNNING: 'error.groupRunning',
LOCAL_MESSAGE_NOT_FOUND: 'error.messageNotFound',
LOCAL_MESSAGE_REQUIRED: 'error.messageRequired',
LOCAL_MESSAGE_TARGET_REQUIRED: 'error.messageTargetRequired',
LOCAL_AGENT_NOT_INSTALLED: 'error.agentUnavailable',
@@ -1267,8 +1414,25 @@ export const DESKTOP_ERROR_MESSAGE_KEYS = Object.freeze({
LOCAL_AGENT_EMPTY_RESPONSE: 'error.agentEmptyResponse',
LOCAL_AGENT_UNKNOWN_FAILURE: 'error.agentUnknownFailure',
LOCAL_AGENT_SPAWN_FAILED: 'error.agentSpawnFailed',
CUSTOM_AGENT_LIMIT: 'error.customAgentLimit',
CUSTOM_AGENT_LABEL_REQUIRED: 'error.customAgentLabel',
CUSTOM_AGENT_DESCRIPTION_INVALID: 'error.customAgentDescription',
CUSTOM_AGENT_ARGUMENTS_INVALID: 'error.customAgentArguments',
CUSTOM_AGENT_SECRET_ARGUMENT_BLOCKED: 'error.customAgentSecret',
CUSTOM_AGENT_PROMPT_MODE_INVALID: 'error.customAgentPromptMode',
CUSTOM_AGENT_EXECUTABLE_INVALID: 'error.customAgentExecutable',
CUSTOM_AGENT_EXECUTABLE_UNSUPPORTED: 'error.customAgentExecutableUnsupported',
CUSTOM_AGENT_ID_UNAVAILABLE: 'error.customAgentUnavailable',
CUSTOM_AGENT_NOT_FOUND: 'error.customAgentUnavailable',
CUSTOM_AGENT_IN_USE: 'error.customAgentInUse',
CUSTOM_AGENT_PROMPT_INVALID: 'error.customAgentPrompt',
CUSTOM_AGENT_OUTPUT_LIMIT: 'error.customAgentOutputLimit',
CUSTOM_AGENT_SPAWN_FAILED: 'error.customAgentSpawn',
CUSTOM_AGENT_PROCESS_FAILED: 'error.customAgentProcess',
LOCAL_AGENT_IMAGE_UNSUPPORTED: 'error.imageUnsupported',
LOCAL_AGENT_IMAGE_LIMIT: 'error.imageLimit',
LOCAL_AGENT_MEDIA_UNSUPPORTED: 'error.mediaUnsupported',
LOCAL_AGENT_MEDIA_LIMIT: 'error.mediaLimit',
LOCAL_ATTACHMENT_BYTES_INVALID: 'error.attachmentInvalid',
LOCAL_ATTACHMENT_COUNT_LIMIT: 'error.attachmentLimit',
LOCAL_ATTACHMENT_CREATE_ID_INVALID: 'error.attachmentUnavailable',
+3
View File
@@ -25,6 +25,7 @@ const OFFICIAL = Object.freeze({
gemini: { provider: 'Google AI Studio', baseUrl: 'https://generativelanguage.googleapis.com/v1beta', model: '' },
opencode: { provider: 'OpenCode Provider', baseUrl: '', model: '' },
qwen: { provider: 'DashScope', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: '' },
opencodereview: { provider: 'OpenAI API', baseUrl: 'https://api.openai.com/v1', model: '' },
})
const RUNTIME_KEYS = Object.freeze({
@@ -38,6 +39,7 @@ const RUNTIME_KEYS = Object.freeze({
gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GEMINI_MODEL'],
opencode: ['OPENAI_API_KEY', 'OPENROUTER_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL'],
qwen: ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL'],
opencodereview: ['OCR_LLM_URL', 'OCR_LLM_TOKEN', 'OCR_LLM_MODEL', 'OCR_USE_ANTHROPIC'],
})
const CONFIG_FILES = Object.freeze({
@@ -51,6 +53,7 @@ const CONFIG_FILES = Object.freeze({
gemini: '~/.gemini/settings.json',
opencode: '~/.local/share/opencode/auth.json',
qwen: '~/.qwen/oauth_creds.json',
opencodereview: '~/.opencodereview/config.json',
})
const PROFILES = Object.freeze(Object.fromEntries(Object.keys(OFFICIAL).map(kind => [kind, {
+555 -28
View File
@@ -22,7 +22,7 @@
--danger: #b74743;
--danger-soft: #fbe8e6;
--shadow: 0 12px 32px rgb(41 62 73 / 0.12);
--shell-gradient: linear-gradient(155deg, #f1f7f5 0%, #e5edef 40%, #d4e2e4 72%, #c4d6db 100%);
--shell-gradient: linear-gradient(145deg, #f1f8f7 0%, #e2efef 48%, #c9dde0 100%);
--onboarding-frame-gradient: linear-gradient(145deg, #dcefeb 0%, #9fc2ca 48%, #ddb5aa 100%);
--workspace-surface: #fbfcfd;
--workspace-shadow: 0 1px 2px rgb(41 62 73 / 0.08), 0 10px 28px rgb(41 62 73 / 0.08);
@@ -56,7 +56,7 @@
--danger: #ee817c;
--danger-soft: #402326;
--shadow: 0 16px 42px rgb(0 0 0 / 0.42);
--shell-gradient: linear-gradient(155deg, #293334 0%, #22292b 38%, #192125 70%, #11191e 100%);
--shell-gradient: linear-gradient(145deg, #2d3a3b 0%, #222e32 48%, #172226 100%);
--onboarding-frame-gradient: linear-gradient(145deg, #4a696b 0%, #2e4751 52%, #775047 100%);
--workspace-surface: #181c1e;
--workspace-shadow: 0 1px 2px rgb(0 0 0 / 0.42), 0 14px 34px rgb(0 0 0 / 0.24);
@@ -259,6 +259,7 @@ button svg {
.onboarding-dialog {
width: min(760px, 100%);
height: min(700px, calc(100dvh - 48px));
max-height: calc(100dvh - 48px);
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
@@ -275,22 +276,35 @@ button svg {
outline: none;
}
.onboarding-slide {
.onboarding-slide-viewport {
min-height: 0;
overflow: auto;
position: relative;
overflow: hidden;
}
.onboarding-slide {
position: absolute;
inset: 0;
display: grid;
grid-template-rows: minmax(0, 1fr) 148px;
min-height: 0;
overflow: hidden;
}
.onboarding-slide > img {
width: 100%;
height: clamp(240px, 46dvh, 420px);
height: 100%;
min-height: 0;
display: block;
border-bottom: 1px solid var(--border);
border-bottom: 0;
background: var(--surface-raised);
object-fit: contain;
}
.onboarding-slide > div {
min-height: 0;
padding: 24px 28px 22px;
overflow: auto;
}
.onboarding-slide h1 {
@@ -313,7 +327,7 @@ button svg {
justify-content: space-between;
gap: 16px;
padding: 12px 16px 12px 20px;
border-top: 1px solid var(--border);
border-top: 0;
background: var(--surface);
}
@@ -409,7 +423,7 @@ button svg {
.onboarding-slide-enter-active,
.onboarding-slide-leave-active {
transition: opacity 0.18s ease;
transition: opacity 0.1s ease;
}
.onboarding-slide-enter-from,
@@ -587,6 +601,47 @@ button svg {
flex-direction: column;
}
.collapsed-group-switcher {
width: var(--sidebar-collapsed-width);
flex: 0 0 auto;
align-self: flex-start;
display: flex;
justify-content: center;
padding: 4px 0 8px;
}
.collapsed-group-switcher-button {
position: relative;
width: 38px;
height: 38px;
color: var(--muted);
}
.collapsed-group-switcher-button:hover,
.collapsed-group-switcher-button:focus-visible,
.collapsed-group-switcher-button.active {
background: var(--surface-hover);
color: var(--text);
}
.collapsed-group-count {
min-width: 15px;
height: 15px;
position: absolute;
top: -2px;
right: -3px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 3px;
border-radius: 999px;
background: var(--accent);
color: var(--accent-contrast);
font-size: 8px;
font-weight: 800;
line-height: 1;
}
.icon-button:hover {
border-color: var(--border);
background: var(--surface-hover);
@@ -1019,6 +1074,149 @@ button svg {
color: var(--accent);
}
.collapsed-group-menu {
width: min(300px, calc(100vw - 96px));
max-height: min(420px, calc(100vh - 32px));
position: fixed;
z-index: 90;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
padding: 8px;
border-radius: var(--radius);
background: var(--surface-raised);
box-shadow: var(--shadow);
overflow: hidden;
}
.collapsed-group-menu-header {
display: grid;
grid-template-columns: 30px minmax(0, 1fr);
align-items: center;
gap: 9px;
padding: 5px 6px 9px;
}
.collapsed-group-menu-header > span:last-child,
.collapsed-group-menu-header strong,
.collapsed-group-menu-header small {
min-width: 0;
display: block;
}
.collapsed-group-menu-header strong {
color: var(--text-soft);
font-size: 12px;
}
.collapsed-group-menu-header small {
margin-top: 2px;
color: var(--muted);
font-size: 10px;
}
.collapsed-group-menu-icon {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: var(--radius-sm);
background: var(--accent-soft);
color: var(--accent);
}
.collapsed-group-menu-icon svg {
width: 16px;
height: 16px;
}
.collapsed-group-menu-list {
min-height: 0;
display: grid;
gap: 2px;
overflow: auto;
}
.collapsed-group-option {
width: 100%;
min-width: 0;
min-height: 48px;
display: grid;
grid-template-columns: 28px minmax(0, 1fr) 34px;
align-items: center;
gap: 9px;
padding: 5px 7px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-soft);
text-align: left;
cursor: pointer;
}
.collapsed-group-option:hover,
.collapsed-group-option:focus-visible {
outline: none;
background: var(--surface-hover);
color: var(--text);
}
.collapsed-group-option.active {
background: var(--surface-active);
color: var(--text);
}
.collapsed-group-option-main,
.collapsed-group-option-main strong,
.collapsed-group-option-main small {
min-width: 0;
display: block;
}
.collapsed-group-option-main strong,
.collapsed-group-option-main small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.collapsed-group-option-main strong {
font-size: 11px;
}
.collapsed-group-option-main small {
margin-top: 2px;
color: var(--muted);
font-size: 9px;
}
.collapsed-group-option-state {
min-width: 0;
display: flex;
align-items: center;
justify-content: flex-end;
color: var(--faint);
}
.collapsed-group-option-state > svg {
width: 15px;
height: 15px;
color: var(--accent);
}
.collapsed-group-option-state time {
overflow: hidden;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.collapsed-group-menu-empty {
margin: 0;
padding: 14px 8px;
color: var(--muted);
font-size: 11px;
}
.conversation-link > span:nth-child(2) {
min-width: 0;
overflow: hidden;
@@ -2433,6 +2631,7 @@ button svg {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 16px 14px 77px;
}
@@ -2490,7 +2689,8 @@ button svg {
.settings-agent-actions {
min-width: 0;
flex: 0 0 auto;
justify-content: flex-start;
margin-left: auto;
justify-content: flex-end;
flex-wrap: nowrap;
gap: 3px;
padding: 0;
@@ -2500,6 +2700,55 @@ button svg {
white-space: nowrap;
}
.agent-catalog-category {
display: grid;
gap: 10px;
}
.agent-catalog-category + .agent-catalog-category {
margin-top: 26px;
}
.agent-catalog-category-header {
display: flex;
align-items: end;
justify-content: space-between;
gap: 16px;
}
.agent-catalog-category-header h2,
.agent-catalog-category-header p {
margin: 0;
}
.agent-catalog-category-header h2 {
color: var(--text-soft);
font-size: 14px;
}
.agent-catalog-category-header p,
.agent-catalog-category-header small {
color: var(--muted);
font-size: 11px;
}
.agent-catalog-category-header p {
margin-top: 3px;
}
.custom-agent-empty {
min-height: 72px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px;
border-radius: var(--radius);
background: color-mix(in srgb, var(--surface) 58%, var(--bg));
color: var(--muted);
font-size: 12px;
}
.settings-agent-card .manager-note,
.settings-agent-card .install-confirm,
.settings-agent-card .install-progress {
@@ -2876,10 +3125,8 @@ button svg {
color: var(--text);
font-size: 17px;
font-weight: 700;
box-shadow: inset 0 -1px 0 var(--border-strong);
transition:
background 0.14s ease,
box-shadow 0.14s ease;
box-shadow: none;
transition: background 0.14s ease;
}
.inline-title-form input:hover {
@@ -2889,7 +3136,7 @@ button svg {
.inline-title-form input:focus-visible {
outline: none;
background: var(--surface-raised);
box-shadow: inset 0 -2px 0 var(--accent);
box-shadow: none;
}
.inline-title-form input::selection {
@@ -3190,7 +3437,7 @@ button svg {
max-width: min(280px, calc(100vw - 96px));
position: absolute;
top: 50%;
left: calc(100% + 12px);
left: calc(100% + 28px);
z-index: 5;
padding: 7px 9px;
border: 0;
@@ -3405,13 +3652,13 @@ button svg {
height: 14px;
}
.message-copy-button {
.message-copy-button,
.message-delete-button {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: auto;
padding: 0;
border: 0;
border-radius: 4px;
@@ -3422,22 +3669,58 @@ button svg {
transition: background 0.14s ease, color 0.14s ease, opacity 0.14s ease;
}
.message-copy-button,
.message-delete-button {
margin-left: auto;
}
.message-copy-button + .message-delete-button {
margin-left: 0;
}
.topic-toggle + .message-copy-button {
margin-left: 0;
}
.message-row:hover .message-copy-button,
.message-row:focus-within .message-copy-button,
.message-row.copied .message-copy-button {
.message-row.copied .message-copy-button,
.message-row:hover .message-delete-button,
.message-row:focus-within .message-delete-button,
.message-delete-button.armed,
.message-delete-button.deleting {
opacity: 1;
}
.message-copy-button:hover,
.message-copy-button:focus-visible {
.message-copy-button:focus-visible,
.message-delete-button:hover,
.message-delete-button:focus-visible {
background: var(--surface-hover);
color: var(--text);
}
.message-delete-button.armed {
background: color-mix(in srgb, var(--danger) 12%, transparent);
color: var(--danger);
}
.message-delete-button:disabled {
cursor: not-allowed;
}
.message-row:hover .message-delete-button:disabled,
.message-row:focus-within .message-delete-button:disabled {
color: var(--faint);
opacity: 0.48;
}
.message-copy-button svg,
.message-delete-button svg {
width: 14px;
height: 14px;
}
.message-row.agent.copied .message-copy-button {
color: var(--agent-accent);
}
@@ -4167,6 +4450,10 @@ button svg {
background: color-mix(in srgb, #79c9af 14%, transparent);
}
.run-status-panel.group .run-agent-logo::before {
content: none;
}
.relay-run-indicator {
width: max-content;
min-width: 57px;
@@ -5154,11 +5441,8 @@ button svg {
}
.mention-menu-section-label {
width: fit-content;
margin: 2px 8px 3px;
padding: 2px 6px;
border-radius: 999px;
background: var(--surface-hover);
display: block;
margin: 3px 8px 4px;
color: var(--muted);
font-size: 9px;
font-weight: 700;
@@ -5325,6 +5609,21 @@ button svg {
object-fit: cover;
}
.composer-attachment-media-icon {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: 4px;
background: var(--surface-hover);
color: var(--accent);
}
.composer-attachment-media-icon svg {
width: 18px;
height: 18px;
}
.composer-attachment > span {
overflow: hidden;
color: var(--text-soft);
@@ -5730,6 +6029,16 @@ button svg {
.modal.agent-detail-modal {
width: min(700px, 100%);
border: 0;
}
.modal.agent-detail-modal .modal-header,
.modal.unlimited-confirm-modal .modal-header {
border-bottom: 0;
}
.modal.unlimited-confirm-modal {
border: 0;
}
.agent-detail-body {
@@ -5848,6 +6157,46 @@ button svg {
margin-top: 2px;
}
.agent-detail-footer .secondary-button,
.agent-detail-footer .primary-button,
.agent-detail-footer .danger-button,
.unlimited-confirm-modal .secondary-button,
.unlimited-confirm-modal .primary-button {
border: 0;
}
.agent-detail-footer .secondary-button,
.unlimited-confirm-modal .secondary-button {
background: var(--surface-hover);
}
.sidebar-visibility-control {
min-height: 52px;
padding: 10px 12px;
border-radius: var(--radius-sm);
background: var(--surface-raised);
}
.sidebar-visibility-control > span:last-child {
display: grid;
gap: 3px;
}
.sidebar-visibility-control strong,
.sidebar-visibility-control small {
display: block;
}
.sidebar-visibility-control strong {
color: var(--text-soft);
font-size: 12px;
}
.sidebar-visibility-control small {
color: var(--muted);
font-size: 11px;
}
@keyframes agent-detail-in {
from { opacity: 0; transform: translateY(6px) scale(0.99); }
}
@@ -5951,7 +6300,7 @@ button svg {
font-weight: 500;
}
.form-stack input:not([type="checkbox"]) {
.form-stack input:not([type="checkbox"]):not([type="radio"]) {
width: 100%;
height: 38px;
padding: 0 11px;
@@ -5961,10 +6310,96 @@ button svg {
color: var(--text);
}
.form-stack input::placeholder {
.form-stack textarea {
width: 100%;
min-height: 82px;
padding: 9px 11px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-raised);
color: var(--text);
font: inherit;
line-height: 1.5;
resize: vertical;
}
.form-stack input::placeholder,
.form-stack textarea::placeholder {
color: var(--faint);
}
.form-stack label > small {
color: var(--muted);
font-size: 10px;
line-height: 1.45;
}
.custom-agent-form {
gap: 14px;
}
.custom-agent-prompt-modes {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 3px;
padding: 3px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-hover);
}
.custom-agent-prompt-modes label {
min-width: 0;
min-height: 34px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 8px;
border-radius: 4px;
color: var(--muted);
font-size: 11px;
font-weight: 650;
cursor: pointer;
}
.custom-agent-prompt-modes label.active {
background: var(--surface-raised);
color: var(--text);
box-shadow: 0 1px 2px rgb(0 0 0 / 0.08);
}
.custom-agent-prompt-modes input {
position: absolute;
inline-size: 1px;
block-size: 1px;
opacity: 0;
pointer-events: none;
}
.custom-agent-security-notice {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: start;
gap: 8px;
padding: 10px 11px;
border: 1px solid color-mix(in srgb, var(--danger) 32%, var(--border));
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--danger) 7%, var(--surface));
color: var(--text-soft);
}
.custom-agent-security-notice svg {
width: 18px;
height: 18px;
color: var(--danger);
}
.custom-agent-security-notice p {
margin: 0;
font-size: 11px;
line-height: 1.55;
}
.input-action-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -6284,6 +6719,12 @@ fieldset:disabled .agent-choice {
font-size: 12px;
}
.manager-toolbar-actions {
display: flex;
align-items: center;
gap: 4px;
}
.agent-manager .manager-toolbar .secondary-button {
border: 0;
background: transparent;
@@ -6459,6 +6900,29 @@ fieldset:disabled .agent-choice {
line-height: 1.45;
}
.copy-toast-message {
position: fixed;
top: calc(var(--desktop-titlebar-height) + 14px);
left: 50%;
z-index: 130;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--surface-raised) 92%, var(--accent-soft));
box-shadow: 0 6px 18px rgb(23 35 40 / 0.16);
color: var(--text-soft);
font-size: 12px;
transform: translateX(-50%);
}
.copy-toast-message svg {
width: 15px;
height: 15px;
color: var(--accent);
}
.toast-message > span {
min-width: 0;
flex: 1;
@@ -6489,6 +6953,68 @@ fieldset:disabled .agent-choice {
transform: translateY(8px);
}
.copy-toast-enter-active,
.copy-toast-leave-active {
transition: opacity 0.14s ease, transform 0.14s ease;
}
.copy-toast-enter-from,
.copy-toast-leave-to {
opacity: 0;
transform: translate(-50%, -5px);
}
.shortcut-menu-anchor {
position: relative;
}
.shortcut-menu {
width: 250px;
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 20;
padding: 10px;
border-radius: var(--radius);
background: var(--surface-raised);
box-shadow: var(--shadow);
}
.shortcut-menu header {
padding: 2px 4px 8px;
color: var(--text-soft);
font-size: 12px;
font-weight: 650;
}
.shortcut-menu ul {
display: grid;
gap: 3px;
margin: 0;
padding: 0;
list-style: none;
}
.shortcut-menu li {
min-height: 28px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 4px;
color: var(--muted);
font-size: 11px;
}
.shortcut-menu kbd {
padding: 2px 5px;
border-radius: 4px;
background: var(--surface-hover);
color: var(--text-soft);
font: 10px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace;
white-space: nowrap;
}
.spinning {
animation: spin 0.8s linear infinite;
}
@@ -6612,8 +7138,9 @@ fieldset:disabled .agent-choice {
@media (max-width: 760px) {
body { overflow: auto; }
.onboarding-backdrop { padding: 12px; }
.onboarding-dialog { max-height: calc(100dvh - 24px); }
.onboarding-slide > img { height: clamp(190px, 40dvh, 300px); aspect-ratio: auto; }
.onboarding-dialog { height: calc(100dvh - 24px); max-height: calc(100dvh - 24px); }
.onboarding-slide { grid-template-rows: minmax(0, 1fr) 154px; }
.onboarding-slide > img { aspect-ratio: auto; }
.onboarding-slide > div { padding: 18px; }
.onboarding-footer { flex-direction: column; align-items: stretch; gap: 10px; padding: 12px; }
.onboarding-dots { justify-content: center; }