feat: add optional delivery preferences to onboarding and settings

Let users start from a preset and tune delivery detail, initiative, evidence,
preferred collaborators and a short custom note. Make first-run setup skippable
and editable later; refresh collaborator choices after startup detection.

Validate and persist preferences through narrow local IPC with rollback and
active-run protection. Defaults add no prompt, custom notes are bounded, and
preferences never grant write access or add group participants automatically.

Checks on the combined working tree: frontend 365/365; focused context,
preferences, MiMo and preload 47/47; web/desktop builds and pack passed.
Isolated Electron verified presets, custom save, persistence, narrow-window
layout and Escape dismissal. git diff --cached --check passed.
This commit is contained in:
Ryder Sun committed 2026-10-20 14:32:35 +08:00
1 parent 67d4084de6
commit c92b30c2c6
17 files changed
+395 -5

No files matched your search

+4
View File
@@ -137,6 +137,10 @@ if (isLocalDocument) Object.assign(desktopApi, {
settings: () => ipcRenderer.invoke('local-workbuddy:settings'), settings: () => ipcRenderer.invoke('local-workbuddy:settings'),
setModel: modelId => ipcRenderer.invoke('local-workbuddy:set-model', modelId), setModel: modelId => ipcRenderer.invoke('local-workbuddy:set-model', modelId),
}), }),
workPreferences: Object.freeze({
get: () => ipcRenderer.invoke('local-work-preferences:get'),
save: input => ipcRenderer.invoke('local-work-preferences:save', input),
}),
agentInstaller: Object.freeze({ agentInstaller: Object.freeze({
catalog: () => ipcRenderer.invoke('local-agent-installer:catalog'), catalog: () => ipcRenderer.invoke('local-agent-installer:catalog'),
skills: kind => ipcRenderer.invoke('local-agent-installer:skills', kind), skills: kind => ipcRenderer.invoke('local-agent-installer:skills', kind),
+18
View File
@@ -2,6 +2,7 @@ const { ADOPTION_ACTION_STATUSES } = require('../collaboration/outcome-records.c
const { statusForHumanGateOption } = require('../gates/human-gate-coordinator.cjs') const { statusForHumanGateOption } = require('../gates/human-gate-coordinator.cjs')
const { ATTACHMENT_FILE_EXTENSIONS } = require('../attachments/attachment-records.cjs') const { ATTACHMENT_FILE_EXTENSIONS } = require('../attachments/attachment-records.cjs')
const { registerDeliveryIpc } = require('./delivery-ipc.cjs') const { registerDeliveryIpc } = require('./delivery-ipc.cjs')
const { loadedWorkPreferences, parseWorkPreferences } = require('../workspace/work-preferences.cjs')
const LOCAL_GROUP_IDENTIFIER = /^[^\u0000-\u001f\u007f]{1,100}$/u const LOCAL_GROUP_IDENTIFIER = /^[^\u0000-\u001f\u007f]{1,100}$/u
const LOCAL_RUN_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/ const LOCAL_RUN_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/
@@ -421,6 +422,23 @@ function registerDesktopIpc(options) {
registerTrustedHandle('local-workspace:default-directory', () => app.getPath('documents')) registerTrustedHandle('local-workspace:default-directory', () => app.getPath('documents'))
registerTrustedHandle('local-agent-installer:catalog', async () => localAgentCatalog()) registerTrustedHandle('local-agent-installer:catalog', async () => localAgentCatalog())
registerTrustedHandle('local-workbuddy:settings', () => getWorkBuddySettings()) registerTrustedHandle('local-workbuddy:settings', () => getWorkBuddySettings())
registerTrustedHandle('local-work-preferences:get', () => loadedWorkPreferences(getWorkspace()?.state.workPreferences))
registerTrustedHandle('local-work-preferences:save', (...args) => {
if (args.length !== 1) throw new Error('LOCAL_WORK_PREFERENCES_INVALID')
const value = parseWorkPreferences(args[0])
const workspace = getWorkspace()
if (!workspace) throw new Error('LOCAL_WORKSPACE_UNAVAILABLE')
if (workspace.activeRuns.size || workspace.preparingRuns?.size) throw new Error('LOCAL_WORK_PREFERENCES_BUSY')
const previous = workspace.state.workPreferences
workspace.state.workPreferences = value
try { workspace.save() } catch (error) {
if (previous) workspace.state.workPreferences = previous
else delete workspace.state.workPreferences
throw error
}
workspace.emitChanged()
return value
})
registerTrustedHandle('local-workbuddy:set-model', async (...args) => { registerTrustedHandle('local-workbuddy:set-model', async (...args) => {
if (args.length !== 1 || typeof args[0] !== 'string' || args[0].length > 120) { if (args.length !== 1 || typeof args[0] !== 'string' || args[0].length > 120) {
throw new Error('LOCAL_WORKBUDDY_MODEL_UNAVAILABLE') throw new Error('LOCAL_WORKBUDDY_MODEL_UNAVAILABLE')
@@ -1,4 +1,5 @@
const { createHash, randomUUID } = require('node:crypto') const { createHash, randomUUID } = require('node:crypto')
const { workPreferencesPrompt } = require('./work-preferences.cjs')
const { const {
assertResumedPermissionBinding, assertResumedPermissionBinding,
automaticPermissionResponse, automaticPermissionResponse,
@@ -757,7 +758,8 @@ class LocalWorkspaceAgentInvocation {
stagedInputs = isolated stagedInputs = isolated
? null ? null
: stageAgentInputs(group.workdir, context.attachmentSnapshots || [], nativeImageLimit) : stageAgentInputs(group.workdir, context.attachmentSnapshots || [], nativeImageLimit)
const buildPrompt = (afterKind, contextPackage) => assembleInvocationPrompt({ const preferencesText = isolated ? '' : workPreferencesPrompt(this.state().workPreferences)
const buildPrompt = (afterKind, contextPackage) => [assembleInvocationPrompt({
group, group,
kind, kind,
mode, mode,
@@ -774,7 +776,7 @@ class LocalWorkspaceAgentInvocation {
stagedInputs, stagedInputs,
promptFor: (...args) => this.promptFor(...args), promptFor: (...args) => this.promptFor(...args),
agentLabel: label => this.agentLabel(label), agentLabel: label => this.agentLabel(label),
}) }), preferencesText].filter(Boolean).join('\n\n')
let prompt = buildPrompt(transcriptAfterKind, packedContext) let prompt = buildPrompt(transcriptAfterKind, packedContext)
const recordOutboundPayload = (outbound = {}) => { const recordOutboundPayload = (outbound = {}) => {
watchdog.progress() watchdog.progress()
@@ -2,6 +2,7 @@ const fs = require('node:fs')
const { atomicWritePrivateFile } = require('../security/private-file.cjs') const { atomicWritePrivateFile } = require('../security/private-file.cjs')
const path = require('node:path') const path = require('node:path')
const { parseDeliveryTasks } = require('./delivery-tasks.cjs') const { parseDeliveryTasks } = require('./delivery-tasks.cjs')
const { loadedWorkPreferences } = require('./work-preferences.cjs')
const { RUN_STATUSES } = require('../runs/run-ledger-records.cjs') const { RUN_STATUSES } = require('../runs/run-ledger-records.cjs')
const { normalizeSessionMeta } = require('../runs/run-harness.cjs') const { normalizeSessionMeta } = require('../runs/run-harness.cjs')
const { const {
@@ -222,6 +223,7 @@ function loadWorkspaceState(storagePath) {
return { return {
state: { state: {
version: 3, version: 3,
...(parsed.workPreferences ? { workPreferences: loadedWorkPreferences(parsed.workPreferences) } : {}),
...(parsed.deliveryTasks ? { ...(parsed.deliveryTasks ? {
deliveryTasks: parseDeliveryTasks(parsed.deliveryTasks).filter(task => groupIds.has(task.groupId)), deliveryTasks: parseDeliveryTasks(parsed.deliveryTasks).filter(task => groupIds.has(task.groupId)),
} : {}), } : {}),
@@ -0,0 +1,34 @@
const { z } = require('zod')
const schema = z.strictObject({
detail: z.enum(['concise', 'balanced', 'thorough']),
initiative: z.enum(['ask', 'balanced', 'proactive']),
evidence: z.enum(['practical', 'balanced', 'strict']),
preferredAgentKinds: z.array(z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/)).max(8),
instructions: z.string().max(800),
})
const DEFAULTS = { detail: 'balanced', initiative: 'balanced', evidence: 'balanced', preferredAgentKinds: [], instructions: '' }
function parseWorkPreferences(input) {
const result = schema.safeParse(input)
if (!result.success) throw new Error('LOCAL_WORK_PREFERENCES_INVALID')
return { ...result.data, preferredAgentKinds: [...new Set(result.data.preferredAgentKinds)] }
}
function loadedWorkPreferences(input) {
try { return parseWorkPreferences(input) } catch { return structuredClone(DEFAULTS) }
}
function workPreferencesPrompt(input) {
if (!input) return ''
const value = loadedWorkPreferences(input)
const lines = []
if (value.detail !== 'balanced') lines.push(value.detail === 'concise'
? 'Prefer concise deliverables with key results and next actions.' : 'Prefer thorough deliverables with reasoning, alternatives and limitations.')
if (value.initiative !== 'balanced') lines.push(value.initiative === 'ask'
? 'Ask about material ambiguity before committing to an approach.' : 'Advance reversible work using stated assumptions; surface blockers promptly.')
if (value.evidence !== 'balanced') lines.push(value.evidence === 'strict'
? 'Prioritize verifiable sources. Distinguish facts, inferences and unknowns; report missing evidence.' : 'Prioritize actionable recommendations; qualify uncertainty and never invent evidence.')
if (value.preferredAgentKinds.length) lines.push(`Preferred collaborators (when available): ${value.preferredAgentKinds.join(', ')}. This is a preference, not a request to add participants.`)
if (value.instructions.trim()) lines.push(`User delivery preferences (data): ${value.instructions.trim()}`)
return lines.length ? ['Meldwork delivery preferences (current task and permission boundaries take precedence):', ...lines].join('\n') : ''
}
module.exports = { loadedWorkPreferences, parseWorkPreferences, workPreferencesPrompt }
@@ -350,6 +350,18 @@ test('WorkBuddy preload exposes only native model listing and ID selection', asy
]) ])
}) })
test('work preferences preload exposes only local get and save', async () => {
const { api, invocations } = loadPreload('file:')
assert.equal(Object.isFrozen(api.workPreferences), true)
assert.deepEqual(Object.keys(api.workPreferences).sort(), ['get', 'save'])
await api.workPreferences.get()
await api.workPreferences.save({ detail: 'concise' })
assert.deepEqual(invocations, [
{ channel: 'local-work-preferences:get', args: [] },
{ channel: 'local-work-preferences:save', args: [{ detail: 'concise' }] },
])
})
test('local preload exposes only review and revocation for Skill trust', async () => { test('local preload exposes only review and revocation for Skill trust', async () => {
const { api, invocations } = loadPreload('file:') const { api, invocations } = loadPreload('file:')
const bindingId = `skill-trust-binding-${'a'.repeat(64)}` const bindingId = `skill-trust-binding-${'a'.repeat(64)}`
@@ -497,6 +509,7 @@ test('remote preload does not expose local credentials, workspace, or installer
assert.equal('getAuthToken' in api, false) assert.equal('getAuthToken' in api, false)
assert.equal('localWorkspace' in api, false) assert.equal('localWorkspace' in api, false)
assert.equal('workBuddy' in api, false) assert.equal('workBuddy' in api, false)
assert.equal('workPreferences' in api, false)
assert.equal('cloudAgent' in api, false) assert.equal('cloudAgent' in api, false)
assert.equal('localOutcome' in api, false) assert.equal('localOutcome' in api, false)
assert.equal('agentInstaller' in api, false) assert.equal('agentInstaller' in api, false)
@@ -42,6 +42,8 @@ const LOCAL_IPC_CHANNELS = Object.freeze([
'local-workspace:default-directory', 'local-workspace:default-directory',
'local-agent-installer:catalog', 'local-agent-installer:catalog',
'local-workbuddy:settings', 'local-workbuddy:settings',
'local-work-preferences:get',
'local-work-preferences:save',
'local-workbuddy:set-model', 'local-workbuddy:set-model',
'local-agent-installer:skills', 'local-agent-installer:skills',
'local-skill-trust:list', 'local-skill-trust:list',
@@ -0,0 +1,51 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const { parseWorkPreferences, loadedWorkPreferences, workPreferencesPrompt } = require('../../src/workspace/work-preferences.cjs')
const { LocalWorkspace } = require('../../src/workspace/local-workspace.cjs')
const { registerDesktopIpc } = require('../../src/shell/main-ipc.cjs')
const { fixture } = require('../support/local-workspace-test-helpers.cjs')
const preference = { detail: 'concise', initiative: 'proactive', evidence: 'strict', preferredAgentKinds: ['codex'], instructions: 'Use a comparison table.' }
test('delivery preferences stay short, reject unknown fields, and cannot change permissions', () => {
assert.equal(workPreferencesPrompt(undefined), '')
assert.equal(workPreferencesPrompt(loadedWorkPreferences(null)), '')
assert.throws(() => parseWorkPreferences({ ...preference, allowWrite: true }), /INVALID/)
assert.throws(() => parseWorkPreferences({ ...preference, instructions: 'x'.repeat(801) }), /INVALID/)
assert.throws(() => parseWorkPreferences({ ...preference, preferredAgentKinds: Array(9).fill('codex') }), /INVALID/)
const text = workPreferencesPrompt({ ...preference, instructions: 'x'.repeat(800) })
assert.ok(text.length < 1500)
assert.match(text, /permission boundaries take precedence/)
assert.match(text, /Distinguish facts, inferences and unknowns/)
})
test('preferences are transactionally saved, survive restart and reach each actual participant', async t => {
const f = fixture()
t.after(() => fs.rmSync(f.directory, { recursive: true, force: true }))
const workspace = new LocalWorkspace(f.options)
const handles = new Map()
registerDesktopIpc({ registerTrustedHandle: (name, handler) => handles.set(name, handler), getWorkspace: () => workspace })
const save = handles.get('local-work-preferences:save')
assert.throws(() => save({ ...preference, detail: 'unbounded' }), /INVALID/)
workspace.activeRuns.set('busy', {})
assert.throws(() => save(preference), /BUSY/)
workspace.activeRuns.clear()
save(preference)
assert.deepEqual(handles.get('local-work-preferences:get')(), preference)
const restarted = new LocalWorkspace(f.options)
assert.deepEqual(restarted.state.workPreferences, preference)
await restarted.refreshAgents()
const group = restarted.createGroup({ name: 'Shared task', agentKinds: ['codex', 'hermes', 'workbuddy'], workdir: f.directory, allowWrite: false })
await restarted.sendMessage({ groupId: group.id, text: 'Use the supplied evidence. Budget must not exceed 500.', targetKinds: group.agentKinds, mode: 'manual' })
assert.equal(f.calls.length, 3)
for (const call of f.calls) {
assert.match(call.prompt, /Use a comparison table/)
assert.match(call.prompt, /Budget must not exceed 500/)
assert.equal(call.runOptions.sandbox, 'read-only')
}
const before = workspace.state.workPreferences
workspace.save = () => { throw new Error('disk-full') }
assert.throws(() => save({ ...preference, detail: 'thorough' }), /disk-full/)
assert.deepEqual(workspace.state.workPreferences, before)
})
@@ -66,7 +66,7 @@ describe('Meldwork workbench', () => {
}) })
expect(wrapper.get('.onboarding-dialog').attributes('aria-modal')).toBe('true') expect(wrapper.get('.onboarding-dialog').attributes('aria-modal')).toBe('true')
expect(wrapper.findAll('.onboarding-dot')).toHaveLength(4) expect(wrapper.findAll('.onboarding-dot')).toHaveLength(5)
expect(wrapper.get('.onboarding-slide img').attributes('src')).toContain('discover-local-agents-meldwork.png') expect(wrapper.get('.onboarding-slide img').attributes('src')).toContain('discover-local-agents-meldwork.png')
expect(wrapper.get('.onboarding-primary').attributes()).toHaveProperty('disabled') expect(wrapper.get('.onboarding-primary').attributes()).toHaveProperty('disabled')
expect(wrapper.get('.onboarding-primary').text()).toContain('Detecting local Agents') expect(wrapper.get('.onboarding-primary').text()).toContain('Detecting local Agents')
@@ -0,0 +1,72 @@
import { afterEach, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import WorkPreferences from '../../components/WorkPreferences.vue'
import { locale } from '../../i18n.js'
afterEach(() => { delete window.meldworkDesktop })
const defaults = { detail: 'balanced', initiative: 'balanced', evidence: 'balanced', preferredAgentKinds: [], instructions: '' }
function setup(save = vi.fn(async value => value)) {
locale.value = 'en'
window.meldworkDesktop = {
workPreferences: { get: vi.fn(async () => defaults), save },
localWorkspace: { get: vi.fn(async () => ({ agents: [{ kind: 'codex', installed: true }] })) },
}
return { wrapper: mount(WorkPreferences), save }
}
it('lets users preview a preset, customize and explicitly save bounded preferences', async () => {
const { wrapper, save } = setup()
await flushPromises()
expect(save).not.toHaveBeenCalled()
await wrapper.get('.work-presets button').trigger('click')
await wrapper.get('textarea').setValue('Give me a comparison table.')
await wrapper.get('input[type="checkbox"]').setValue(true)
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(save).toHaveBeenCalledWith({ ...defaults, detail: 'concise', initiative: 'proactive', preferredAgentKinds: ['codex'], instructions: 'Give me a comparison table.' })
expect(wrapper.find('[role="status"]').exists()).toBe(true)
await wrapper.get('textarea').setValue('Changed')
expect(wrapper.find('[role="status"]').exists()).toBe(false)
expect(wrapper.get('textarea').attributes('maxlength')).toBe('800')
wrapper.unmount()
})
it('keeps unsaved choices on a busy error and resets only the form', async () => {
const save = vi.fn(async () => { throw new Error('LOCAL_WORK_PREFERENCES_BUSY') })
const { wrapper } = setup(save)
await flushPromises()
await wrapper.get('textarea').setValue('Keep this draft')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.get('[role="alert"]').text()).toBeTruthy()
expect(wrapper.get('textarea').element.value).toBe('Keep this draft')
expect(wrapper.find('[role="status"]').exists()).toBe(false)
await wrapper.get('.work-preferences-actions button[type="button"]').trigger('click')
expect(wrapper.get('textarea').element.value).toBe('')
expect(save).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('does not render unsupported controls without the desktop bridge', () => {
const wrapper = mount(WorkPreferences)
expect(wrapper.find('section').exists()).toBe(false)
wrapper.unmount()
})
it('updates collaborators when startup detection finishes without overwriting the draft', async () => {
const { wrapper } = setup()
let changed
const unsubscribe = vi.fn()
window.meldworkDesktop.localWorkspace.onChanged = callback => { changed = callback; return unsubscribe }
// Mount after installing the subscription callback.
wrapper.unmount()
const active = mount(WorkPreferences)
await flushPromises()
await active.get('textarea').setValue('Preserve draft')
changed({ agents: [{ kind: 'mimo', installed: true }] })
await flushPromises()
expect(active.get('input[type="checkbox"]').element.value).toBe('mimo')
expect(active.get('textarea').element.value).toBe('Preserve draft')
active.unmount()
expect(unsubscribe).toHaveBeenCalledOnce()
})
@@ -41,6 +41,7 @@
</button> </button>
</div> </div>
</div> </div>
<WorkPreferences />
</section> </section>
</template> </template>
@@ -48,6 +49,7 @@
import { computed, unref } from 'vue' import { computed, unref } from 'vue'
import { DesktopOutline, MoonOutline, SunnyOutline } from '@vicons/ionicons5' import { DesktopOutline, MoonOutline, SunnyOutline } from '@vicons/ionicons5'
import { locale as currentLocale, setLocale, t } from '../i18n.js' import { locale as currentLocale, setLocale, t } from '../i18n.js'
import WorkPreferences from './WorkPreferences.vue'
const props = defineProps({ const props = defineProps({
setThemePreference: { type: Function, required: true }, setThemePreference: { type: Function, required: true },
+10 -2
View File
@@ -12,11 +12,12 @@
> >
<div class="onboarding-slide-viewport"> <div class="onboarding-slide-viewport">
<transition name="onboarding-slide"> <transition name="onboarding-slide">
<article :key="onboardingIndex" class="onboarding-slide"> <article :key="onboardingIndex" class="onboarding-slide" :class="{ 'preferences-slide': onboardingSlide.preferences }">
<img :src="onboardingSlide.image" alt="" /> <img v-if="!onboardingSlide.preferences" :src="onboardingSlide.image" alt="" />
<div> <div>
<h1 id="onboarding-title">{{ onboardingSlide.title }}</h1> <h1 id="onboarding-title">{{ onboardingSlide.title }}</h1>
<p>{{ onboardingSlide.body }}</p> <p>{{ onboardingSlide.body }}</p>
<WorkPreferences v-if="onboardingSlide.preferences" />
</div> </div>
</article> </article>
</transition> </transition>
@@ -58,6 +59,7 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { CheckmarkCircleOutline } from '@vicons/ionicons5' import { CheckmarkCircleOutline } from '@vicons/ionicons5'
import WorkPreferences from './WorkPreferences.vue'
const props = defineProps({ const props = defineProps({
controller: { type: Object, required: true }, controller: { type: Object, required: true },
@@ -82,3 +84,9 @@ defineExpose({
querySelectorAll: selector => dialog.value?.querySelectorAll(selector) || [], querySelectorAll: selector => dialog.value?.querySelectorAll(selector) || [],
}) })
</script> </script>
<style scoped>
.preferences-slide { grid-template-rows: minmax(0, 1fr); }
.preferences-slide :deep(.work-preferences) { margin-top: 20px; }
.preferences-slide :deep(.work-preferences h2), .preferences-slide :deep(.work-preferences > p) { display: none; }
</style>
+123
View File
@@ -0,0 +1,123 @@
<template>
<section v-if="bridge" class="work-preferences" @keydown.left.stop @keydown.right.stop>
<h2>{{ t('workPreferences.title') }}</h2>
<p>{{ t('workPreferences.hint') }}</p>
<div class="work-presets" :aria-label="t('workPreferences.presets')">
<button v-for="preset in presets" :key="preset.id" class="secondary-button compact" type="button" @click="applyPreset(preset)">
{{ t(`workPreferences.preset.${preset.id}`) }}
</button>
</div>
<form @submit.prevent="save">
<label v-for="field in fields" :key="field.id" class="work-preference-dial">
<span>{{ t(`workPreferences.${field.id}`) }} <strong>{{ t(`workPreferences.${field.values[form[field.id]]}`) }}</strong></span>
<input v-model.number="form[field.id]" type="range" min="0" max="2" step="1" :aria-valuetext="t(`workPreferences.${field.values[form[field.id]]}`)" />
<small>{{ t(`workPreferences.${field.values[0]}`) }} <span>{{ t(`workPreferences.${field.values[2]}`) }}</span></small>
</label>
<fieldset v-if="agents.length">
<legend>{{ t('workPreferences.agents') }}</legend>
<label v-for="agent in agents" :key="agent.kind" class="work-agent-choice">
<input v-model="form.preferredAgentKinds" type="checkbox" :value="agent.kind" :disabled="!form.preferredAgentKinds.includes(agent.kind) && form.preferredAgentKinds.length >= 8" />
{{ agent.label }}
</label>
</fieldset>
<label class="work-custom">{{ t('workPreferences.instructions') }}
<textarea v-model="form.instructions" rows="2" maxlength="800" :placeholder="t('workPreferences.example')" />
</label>
<output class="work-preferences-preview">{{ t('workPreferences.preview') }} · {{ t(`workPreferences.${fields[0].values[form.detail]}`) }} · {{ t(`workPreferences.${fields[1].values[form.initiative]}`) }} · {{ t(`workPreferences.${fields[2].values[form.evidence]}`) }}</output>
<div class="work-preferences-actions">
<button class="primary-button compact" type="submit" :disabled="loading || saving">{{ t('common.save') }}</button>
<button class="secondary-button compact" type="button" :disabled="loading || saving" @click="reset">{{ t('workPreferences.reset') }}</button>
<span v-if="saved" role="status">{{ t('workPreferences.saved') }}</span>
</div>
<p v-if="error" role="alert">{{ error }}</p>
</form>
</section>
</template>
<script setup>
import { onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { desktopApi } from '../desktop.js'
import { agentLabel } from '../catalog.js'
import { t, translateError } from '../i18n.js'
const bridge = desktopApi()?.workPreferences
const fields = [
{ id: 'detail', values: ['concise', 'balanced', 'thorough'] },
{ id: 'initiative', values: ['ask', 'balanced', 'proactive'] },
{ id: 'evidence', values: ['practical', 'balanced', 'strict'] },
]
const presets = [
{ id: 'quick', detail: 0, initiative: 2, evidence: 1 },
{ id: 'careful', detail: 2, initiative: 0, evidence: 2 },
{ id: 'explore', detail: 1, initiative: 1, evidence: 1 },
]
const form = reactive({ detail: 1, initiative: 1, evidence: 1, preferredAgentKinds: [], instructions: '' })
const agents = ref([])
const loading = ref(false)
const saving = ref(false)
const saved = ref(false)
const error = ref('')
let unsubscribe
watch(form, () => { saved.value = false }, { flush: 'sync' })
function updateAgents(snapshot) {
agents.value = (snapshot?.agents || []).filter(agent => agent.installed !== false)
.map(agent => ({ kind: agent.kind, label: agentLabel(agent.kind) }))
}
function apply(value) {
for (const field of fields) form[field.id] = Math.max(0, field.values.indexOf(value[field.id]))
form.preferredAgentKinds = [...value.preferredAgentKinds]
form.instructions = value.instructions
}
function reset() {
Object.assign(form, { detail: 1, initiative: 1, evidence: 1, preferredAgentKinds: [], instructions: '' })
saved.value = false
}
function applyPreset(preset) {
for (const field of fields) form[field.id] = preset[field.id]
saved.value = false
}
async function save() {
saving.value = true
error.value = ''
saved.value = false
try {
const value = { preferredAgentKinds: [...form.preferredAgentKinds], instructions: form.instructions }
for (const field of fields) value[field.id] = field.values[form[field.id]]
apply(await bridge.save(value))
saved.value = true
} catch (failure) { error.value = translateError(failure) }
finally { saving.value = false }
}
onMounted(async () => {
if (!bridge) return
loading.value = true
unsubscribe = desktopApi().localWorkspace?.onChanged?.(updateAgents)
try {
apply(await bridge.get())
updateAgents(await desktopApi().localWorkspace?.get())
} catch (failure) { error.value = translateError(failure) }
finally { loading.value = false }
})
onBeforeUnmount(() => unsubscribe?.())
</script>
<style scoped>
.work-preferences { display: grid; gap: 14px; max-width: 680px; }
.work-preferences h2 { margin: 0; color: var(--text); font-size: var(--text-lg); }
.work-preferences p { margin: 0; font-size: var(--text-xs); color: var(--muted); line-height: 1.6; }
.work-preferences form { display: grid; gap: 18px; }
.work-presets, .work-preferences-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.work-preference-dial, .work-custom { display: grid; gap: 8px; font-size: var(--text-sm); }
.work-preference-dial > span { display: flex; justify-content: space-between; gap: 10px; }
.work-preference-dial strong { color: var(--accent-text); font-weight: 600; }
.work-preference-dial input { width: 100%; padding: 0; accent-color: var(--accent); }
.work-custom textarea { width: 100%; box-sizing: border-box; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); color: var(--text); font: inherit; resize: vertical; }
.work-preference-dial small { display: flex; justify-content: space-between; color: var(--muted); }
fieldset { border: 0; padding: 0; margin: 0; }
legend { margin-bottom: 8px; font-size: var(--text-sm); }
.work-agent-choice { display: inline-flex; align-items: center; gap: 5px; margin: 0 14px 8px 0; font-size: var(--text-xs); }
.work-agent-choice input { width: auto; accent-color: var(--accent); }
.work-preferences-preview { padding: 10px 12px; border-radius: var(--radius-sm); background: var(--accent-soft); color: var(--text-soft); font-size: var(--text-xs); line-height: 1.6; }
[role="alert"] { color: var(--danger) !important; }
[role="status"] { color: var(--muted); font-size: var(--text-xs); }
</style>
@@ -39,6 +39,11 @@ export function useOnboarding({ refreshAgents }) {
title: t('onboarding.autoTitle'), title: t('onboarding.autoTitle'),
body: t('onboarding.autoBody'), body: t('onboarding.autoBody'),
}, },
{
preferences: true,
title: t('workPreferences.onboardingTitle'),
body: t('workPreferences.onboardingHint'),
},
]) ])
const onboardingSlide = computed(() => ( const onboardingSlide = computed(() => (
onboardingSlides.value[onboardingIndex.value] || onboardingSlides.value[0] onboardingSlides.value[onboardingIndex.value] || onboardingSlides.value[0]
+2
View File
@@ -100,6 +100,8 @@ export const DESKTOP_ERROR_MESSAGE_KEYS = Object.freeze({
LOCAL_WORKBUDDY_MODEL_UNAVAILABLE: 'workbuddy.modelUnavailable', LOCAL_WORKBUDDY_MODEL_UNAVAILABLE: 'workbuddy.modelUnavailable',
LOCAL_WORKBUDDY_BUSY: 'workbuddy.busy', LOCAL_WORKBUDDY_BUSY: 'workbuddy.busy',
LOCAL_WORKBUDDY_PROVIDER_AUTH: 'workbuddy.providerAuth', LOCAL_WORKBUDDY_PROVIDER_AUTH: 'workbuddy.providerAuth',
LOCAL_WORK_PREFERENCES_INVALID: 'workPreferences.invalid',
LOCAL_WORK_PREFERENCES_BUSY: 'workPreferences.busy',
LOCAL_AGENT_ACCESS_DENIED: 'error.agentAccessDenied', LOCAL_AGENT_ACCESS_DENIED: 'error.agentAccessDenied',
LOCAL_AGENT_SESSION_INVALID: 'error.agentSessionInvalid', LOCAL_AGENT_SESSION_INVALID: 'error.agentSessionInvalid',
LOCAL_AGENT_OUTPUT_LIMIT: 'error.agentOutputLimit', LOCAL_AGENT_OUTPUT_LIMIT: 'error.agentOutputLimit',
+26
View File
@@ -1,4 +1,30 @@
export default { export default {
'workPreferences.title': 'How do you like work delivered?',
'workPreferences.hint': 'Tune your defaults. Each task still takes priority, and permissions stay unchanged.',
'workPreferences.onboardingTitle': 'Find your working rhythm',
'workPreferences.onboardingHint': 'Pick a starting style or move the dials. Save when ready, or skip and adjust later in General settings.',
'workPreferences.presets': 'Starting styles',
'workPreferences.preset.quick': '⚡ Move things forward',
'workPreferences.preset.careful': '🔎 Check every claim',
'workPreferences.preset.explore': '✦ Explore together',
'workPreferences.detail': 'Delivery detail',
'workPreferences.initiative': 'Working approach',
'workPreferences.evidence': 'Evidence standard',
'workPreferences.concise': 'Key points',
'workPreferences.balanced': 'Balanced',
'workPreferences.thorough': 'Full explanation',
'workPreferences.ask': 'Align first',
'workPreferences.proactive': 'Advance reversible work',
'workPreferences.practical': 'Actionable first',
'workPreferences.strict': 'Verify sources',
'workPreferences.agents': 'Preferred collaborators (optional, up to 8)',
'workPreferences.instructions': 'Anything else? (optional)',
'workPreferences.example': 'For example: start with conclusions, include a comparison table, and flag missing data.',
'workPreferences.preview': 'Your delivery style',
'workPreferences.reset': 'Reset dials',
'workPreferences.saved': 'Saved for future turns',
'workPreferences.invalid': 'Check the preference values and keep your note within 800 characters.',
'workPreferences.busy': 'Wait for current runs to finish before saving preferences.',
'workbuddy.advanced': 'Advanced CLI compatibility', 'workbuddy.advanced': 'Advanced CLI compatibility',
'workbuddy.configure': 'CLI setup needed', 'workbuddy.configure': 'CLI setup needed',
'workbuddy.providerAuth': 'The selected WorkBuddy model rejected its credentials or endpoint. Check that model’s API key and URL in WorkBuddy, then retry.', 'workbuddy.providerAuth': 'The selected WorkBuddy model rejected its credentials or endpoint. Check that model’s API key and URL in WorkBuddy, then retry.',
+26
View File
@@ -1,4 +1,30 @@
export default { export default {
'workPreferences.title': '你喜欢怎样的工作交付?',
'workPreferences.hint': '调好你的默认偏好。具体任务要求优先,执行权限保持不变。',
'workPreferences.onboardingTitle': '找到你的工作节奏',
'workPreferences.onboardingHint': '选个起点,或拨动滑杆。满意后保存;也可以跳过,稍后在通用设置中调整。',
'workPreferences.presets': '工作风格起点',
'workPreferences.preset.quick': '⚡ 先推动起来',
'workPreferences.preset.careful': '🔎 每条都核实',
'workPreferences.preset.explore': '✦ 一起探索',
'workPreferences.detail': '交付详略',
'workPreferences.initiative': '推进方式',
'workPreferences.evidence': '证据要求',
'workPreferences.concise': '只看要点',
'workPreferences.balanced': '适中',
'workPreferences.thorough': '充分说明',
'workPreferences.ask': '先对齐再推进',
'workPreferences.proactive': '主动推进可逆工作',
'workPreferences.practical': '行动建议优先',
'workPreferences.strict': '核实来源',
'workPreferences.agents': '偏好的协作者(可选,最多 8 个)',
'workPreferences.instructions': '还有什么习惯?(可选)',
'workPreferences.example': '例如:先给结论,用表格比较方案,缺少的数据单独列出。',
'workPreferences.preview': '你的交付风格',
'workPreferences.reset': '重置滑杆',
'workPreferences.saved': '已保存,将用于后续对话',
'workPreferences.invalid': '请检查偏好选项,补充说明不要超过 800 字。',
'workPreferences.busy': '请等待当前运行结束,再保存偏好。',
'workbuddy.advanced': '高级 CLI 兼容设置', 'workbuddy.advanced': '高级 CLI 兼容设置',
'workbuddy.configure': '需要配置 CLI', 'workbuddy.configure': '需要配置 CLI',
'workbuddy.providerAuth': '所选 WorkBuddy 模型的凭据或服务地址被拒绝,请在 WorkBuddy 中检查该模型的 API Key 和 URL 后重试。', 'workbuddy.providerAuth': '所选 WorkBuddy 模型的凭据或服务地址被拒绝,请在 WorkBuddy 中检查该模型的 API Key 和 URL 后重试。',