From f4e90ca23279fa20e1d5969dfb2350d0f164449f Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Fri, 26 Jun 2026 19:37:45 +0300 Subject: [PATCH] fix(agents): stop falsely reporting saved agent edits on external OpenCode; make model-selector shortcut customizable (#1839) * feat(shortcuts): make 'Open model selector' shortcut customizable Lets users remap the model selector shortcut (e.g. to Ctrl+M) via Settings > OpenChamber > Shortcuts, matching OpenCode's quick model-switch keybinding workflow. Co-authored-by: Serhii Dziupin * fix(agents): surface manual-restart needed when on external OpenCode Agent prompt/permission/settings edits are written to disk, but an external OpenCode server (skip-start or auto-detected on the default port) is not owned by OpenChamber and is only health-probed on config change, so it keeps serving its startup-cached config until restarted. The API previously claimed a successful reload, so the UI silently reverted the edit to the stale/default value on refresh. Now refreshOpenCodeAfterConfigChange reports whether a real reload happened; agent routes return requiresManualRestart for external mode; and the agents UI keeps the saved values and warns the user to restart their OpenCode server instead of showing a false success. Managed mode behavior is unchanged (process is restarted and reload is live). Co-authored-by: Serhii Dziupin --------- Co-authored-by: Serhii Dziupin --- .../components/sections/agents/AgentsPage.tsx | 18 +++-- .../sections/agents/AgentsSidebar.tsx | 22 ++++--- .../ui/src/lib/i18n/messages/en.settings.ts | 3 + .../ui/src/lib/i18n/messages/es.settings.ts | 3 + .../ui/src/lib/i18n/messages/fr.settings.ts | 3 + .../ui/src/lib/i18n/messages/ko.settings.ts | 3 + .../ui/src/lib/i18n/messages/pl.settings.ts | 3 + .../src/lib/i18n/messages/pt-BR.settings.ts | 3 + .../ui/src/lib/i18n/messages/uk.settings.ts | 3 + .../src/lib/i18n/messages/zh-CN.settings.ts | 3 + .../src/lib/i18n/messages/zh-TW.settings.ts | 3 + packages/ui/src/lib/shortcuts.ts | 1 + packages/ui/src/stores/useAgentsStore.ts | 66 ++++++++++++++----- .../lib/opencode/config-entity-routes.js | 60 +++++++++++------ packages/web/server/lib/opencode/lifecycle.js | 14 +++- 15 files changed, 155 insertions(+), 53 deletions(-) diff --git a/packages/ui/src/components/sections/agents/AgentsPage.tsx b/packages/ui/src/components/sections/agents/AgentsPage.tsx index a78f7110..6db27c18 100644 --- a/packages/ui/src/components/sections/agents/AgentsPage.tsx +++ b/packages/ui/src/components/sections/agents/AgentsPage.tsx @@ -4,7 +4,7 @@ import { Input } from '@/components/ui/input'; import { NumberInput } from '@/components/ui/number-input'; import { Textarea } from '@/components/ui/textarea'; import { toast } from '@/components/ui'; -import { useAgentsStore, type AgentConfig, type AgentScope } from '@/stores/useAgentsStore'; +import { useAgentsStore, type AgentConfig, type AgentMutationResult, type AgentScope } from '@/stores/useAgentsStore'; import { useShallow } from 'zustand/react/shallow'; import { useDirectorySync } from '@/sync/sync-context'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; @@ -635,18 +635,22 @@ export const AgentsPage: React.FC = () => { scope: isNewAgent ? draftScope : undefined, }; - let success: boolean; + let result: AgentMutationResult; if (isNewAgent) { - success = await createAgent(config); - if (success) { + result = await createAgent(config); + if (result.ok) { setAgentDraft(null); // Clear draft after successful creation } } else { - success = await updateAgent(agentName, config); + result = await updateAgent(agentName, config); } - if (success) { - toast.success(isNewAgent ? t('settings.agents.page.toast.created') : t('settings.agents.page.toast.updated')); + if (result.ok) { + if (result.requiresManualRestart) { + toast.warning(t('settings.agents.page.toast.savedManualRestart')); + } else { + toast.success(isNewAgent ? t('settings.agents.page.toast.created') : t('settings.agents.page.toast.updated')); + } } else { toast.error(isNewAgent ? t('settings.agents.page.toast.createFailed') : t('settings.agents.page.toast.updateFailed')); } diff --git a/packages/ui/src/components/sections/agents/AgentsSidebar.tsx b/packages/ui/src/components/sections/agents/AgentsSidebar.tsx index c3622529..778c94a1 100644 --- a/packages/ui/src/components/sections/agents/AgentsSidebar.tsx +++ b/packages/ui/src/components/sections/agents/AgentsSidebar.tsx @@ -182,10 +182,12 @@ export const AgentsSidebar: React.FC = ({ onItemSelect }) => setIsConfirmActionPending(true); try { - const success = await deleteAgent(confirmActionAgent.name, (confirmActionAgent as Agent & { scope?: AgentScope }).scope); + const result = await deleteAgent(confirmActionAgent.name, (confirmActionAgent as Agent & { scope?: AgentScope }).scope); - if (success) { - if (confirmActionType === 'delete') { + if (result.ok) { + if (result.requiresManualRestart) { + toast.warning(t('settings.agents.page.toast.savedManualRestart')); + } else if (confirmActionType === 'delete') { toast.success(t('settings.agents.sidebar.toast.agentDeleted', { name: confirmActionAgent.name })); } else { toast.success(t('settings.agents.sidebar.toast.agentReset', { name: confirmActionAgent.name })); @@ -274,7 +276,7 @@ export const AgentsSidebar: React.FC = ({ onItemSelect }) => ? `${renameDialogAgent.model.providerID}/${renameDialogAgent.model.modelID}` : null; const renameExt = renameDialogAgent as Agent & { scope?: AgentScope; disable?: boolean }; - const success = await createAgent({ + const createResult = await createAgent({ name: sanitizedName, description: renameDialogAgent.description, model: renameModelStr, @@ -288,11 +290,15 @@ export const AgentsSidebar: React.FC = ({ onItemSelect }) => scope: renameExt.scope, }); - if (success) { + if (createResult.ok) { // Delete old agent - const deleteSuccess = await deleteAgent(renameDialogAgent.name, renameExt.scope); - if (deleteSuccess) { - toast.success(`Agent renamed to "${sanitizedName}"`); + const deleteResult = await deleteAgent(renameDialogAgent.name, renameExt.scope); + if (deleteResult.ok) { + if (createResult.requiresManualRestart || deleteResult.requiresManualRestart) { + toast.warning(t('settings.agents.page.toast.savedManualRestart')); + } else { + toast.success(t('settings.agents.sidebar.toast.agentRenamed', { name: sanitizedName })); + } setSelectedAgent(sanitizedName); } else { toast.error(t('settings.agents.sidebar.toast.removeOldAfterRenameFailed')); diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 108a193a..9012bd25 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -398,12 +398,14 @@ export const settingsDict = { 'settings.agents.sidebar.toast.agentExists': 'An agent with this name already exists', 'settings.agents.sidebar.toast.removeOldAfterRenameFailed': 'Failed to remove old agent after rename', 'settings.agents.sidebar.toast.renameFailed': 'Failed to rename agent', + 'settings.agents.sidebar.toast.agentRenamed': 'Agent renamed to "{name}"', 'settings.agents.page.toast.permissionNameRequired': 'Permission name is required', 'settings.agents.page.toast.created': 'Agent created successfully', 'settings.agents.page.toast.updated': 'Agent updated successfully', 'settings.agents.page.toast.createFailed': 'Failed to create agent', 'settings.agents.page.toast.updateFailed': 'Failed to update agent', 'settings.agents.page.toast.saveUnexpectedError': 'An error occurred while saving', + 'settings.agents.page.toast.savedManualRestart': 'Saved to disk. Restart your connected OpenCode server to apply the changes.', 'settings.agents.page.empty.title': 'Select an agent from the sidebar', 'settings.agents.page.empty.description': 'or create a new one', 'settings.agents.page.title.new': 'New Agent', @@ -959,6 +961,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': 'Cycle agent', 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': 'Cycle favorite model forward', 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_backward.label': 'Cycle favorite model backward', + 'settings.openchamber.keyboardShortcuts.action.open_model_selector.label': 'Open model selector', 'settings.openchamber.keyboardShortcuts.action.expand_input.label': 'Expand input', 'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': 'Open conversation timeline', 'settings.projects.sidebar.total': 'Total {count}', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index f93ae94e..9a2c92f3 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -365,12 +365,14 @@ export const settingsDict = { "settings.agents.sidebar.toast.agentExists": "Ya existe un agente con este nombre", "settings.agents.sidebar.toast.removeOldAfterRenameFailed": "No se pudo eliminar el antiguo agente después del cambio de nombre", "settings.agents.sidebar.toast.renameFailed": "No se pudo cambiar el nombre del agente", + "settings.agents.sidebar.toast.agentRenamed": "Agente renombrado a \"{name}\"", "settings.agents.page.toast.permissionNameRequired": "Se requiere el nombre del permiso", "settings.agents.page.toast.created": "Agente creado correctamente", "settings.agents.page.toast.updated": "Agente actualizado correctamente", "settings.agents.page.toast.createFailed": "No se pudo crear el agente", "settings.agents.page.toast.updateFailed": "No se pudo actualizar el agente", "settings.agents.page.toast.saveUnexpectedError": "Ocurrió un error al guardar", + "settings.agents.page.toast.savedManualRestart": "Guardado en disco. Reinicia tu servidor OpenCode conectado para aplicar los cambios.", "settings.agents.page.empty.title": "Selecciona un agente desde la barra lateral", "settings.agents.page.empty.description": "o crea uno nuevo", "settings.agents.page.title.new": "Nuevo agente", @@ -926,6 +928,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.cycle_agent.label": "Cambiar agente", "settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label": "Siguiente modelo favorito", "settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_backward.label": "Modelo favorito anterior", + "settings.openchamber.keyboardShortcuts.action.open_model_selector.label": "Abrir selector de modelo", "settings.openchamber.keyboardShortcuts.action.expand_input.label": "Expandir entrada", "settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label": "Abrir línea de tiempo de conversación", "settings.projects.sidebar.total": "Total {count}", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 9cb6220a..a8bd66e9 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -354,12 +354,14 @@ export const settingsDict = { 'settings.agents.sidebar.toast.agentExists': 'Un agent portant ce nom existe déjà', 'settings.agents.sidebar.toast.removeOldAfterRenameFailed': 'Échec de la suppression de l\'ancien agent après le changement de nom', 'settings.agents.sidebar.toast.renameFailed': 'Échec du changement de nom de l\'agent', + 'settings.agents.sidebar.toast.agentRenamed': 'Agent renommé en "{name}"', 'settings.agents.page.toast.permissionNameRequired': 'Le nom de l\'autorisation est requis', 'settings.agents.page.toast.created': 'Agent créé avec succès', 'settings.agents.page.toast.updated': 'Agent mis à jour avec succès', 'settings.agents.page.toast.createFailed': 'Échec de la création de l\'agent', 'settings.agents.page.toast.updateFailed': 'Échec de la mise à jour de l\'agent', 'settings.agents.page.toast.saveUnexpectedError': 'Une erreur s\'est produite lors de l\'enregistrement', + 'settings.agents.page.toast.savedManualRestart': 'Enregistré sur le disque. Redémarrez votre serveur OpenCode connecté pour appliquer les modifications.', 'settings.agents.page.empty.title': 'Sélectionnez un agent dans la barre latérale', 'settings.agents.page.empty.description': 'ou créez-en un nouveau', 'settings.agents.page.title.new': 'Nouvel agent', @@ -915,6 +917,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': 'Agent de cycle', 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': 'Faire avancer le modèle favori', 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_backward.label': 'Faire défiler le modèle favori vers l\'arrière', + 'settings.openchamber.keyboardShortcuts.action.open_model_selector.label': 'Ouvrir le sélecteur de modèle', 'settings.openchamber.keyboardShortcuts.action.expand_input.label': 'Développer l\'entrée', 'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': 'Chronologie de la conversation ouverte', 'settings.projects.sidebar.total': 'Total {count}', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 8c9162f1..d898681b 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -365,12 +365,14 @@ export const settingsDict = { 'settings.agents.sidebar.toast.agentExists': '같은 이름의 에이전트가 이미 존재합니다', 'settings.agents.sidebar.toast.removeOldAfterRenameFailed': '이름 변경 후 이전 에이전트를 제거하지 못했습니다', 'settings.agents.sidebar.toast.renameFailed': '에이전트 이름을 변경하지 못했습니다', + 'settings.agents.sidebar.toast.agentRenamed': '에이전트 이름이 "{name}"(으)로 변경되었습니다', 'settings.agents.page.toast.permissionNameRequired': '권한 이름은 필수입니다', 'settings.agents.page.toast.created': '에이전트를 생성했습니다', 'settings.agents.page.toast.updated': '에이전트를 업데이트했습니다', 'settings.agents.page.toast.createFailed': '에이전트를 생성하지 못했습니다', 'settings.agents.page.toast.updateFailed': '에이전트를 업데이트하지 못했습니다', 'settings.agents.page.toast.saveUnexpectedError': '저장 중 오류가 발생했습니다', + 'settings.agents.page.toast.savedManualRestart': '디스크에 저장되었습니다. 변경 사항을 적용하려면 연결된 OpenCode 서버를 다시 시작하세요.', 'settings.agents.page.empty.title': '사이드바에서 에이전트를 선택하세요', 'settings.agents.page.empty.description': '또는 새로 생성하세요', 'settings.agents.page.title.new': '새 에이전트', @@ -926,6 +928,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': '에이전트 순환', 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': '즐겨찾기 모델 앞으로 순환', 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_backward.label': '즐겨찾기 모델 뒤로 순환', + 'settings.openchamber.keyboardShortcuts.action.open_model_selector.label': '모델 선택기 열기', 'settings.openchamber.keyboardShortcuts.action.expand_input.label': '입력 확장', 'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': '대화 타임라인 열기', 'settings.projects.sidebar.total': '총 {count}개', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 5d305ec3..da16e9c5 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -62,6 +62,7 @@ export const settingsDict = { 'settings.agents.page.toast.created': 'Agent został utworzony pomyślnie', 'settings.agents.page.toast.permissionNameRequired': 'Nazwa uprawnienia jest wymagana', 'settings.agents.page.toast.saveUnexpectedError': 'Wystąpił błąd podczas zapisywania', + 'settings.agents.page.toast.savedManualRestart': 'Zapisano na dysku. Uruchom ponownie połączony serwer OpenCode, aby zastosować zmiany.', 'settings.agents.page.toast.updateFailed': 'Nie udało się zaktualizować agenta', 'settings.agents.page.toast.updated': 'Agent został zaktualizowany pomyślnie', 'settings.agents.sidebar.badge.system': 'system', @@ -86,6 +87,7 @@ export const settingsDict = { 'settings.agents.sidebar.toast.definitionNotFound': 'Nie znaleziono definicji agenta. Nic nie zmieniono.', 'settings.agents.sidebar.toast.removeOldAfterRenameFailed': 'Nie udało się usunąć starego agenta po zmianie nazwy', 'settings.agents.sidebar.toast.renameFailed': 'Nie udało się zmienić nazwy agenta', + 'settings.agents.sidebar.toast.agentRenamed': 'Zmieniono nazwę agenta na "{name}"', 'settings.agents.sidebar.toast.resetFailed': 'Nie udało się zresetować agenta', 'settings.agents.sidebar.total': 'Suma: {count}', 'settings.behavior.page.field.systemPromptPlaceholder': 'Jesteś pomocnym asystentem AI...\n\nUżyj tej przestrzeni, aby zdefiniować absolutne reguły zachowania AI we wszystkich sesjach i u dostawców.', @@ -713,6 +715,7 @@ export const settingsDict = { 'settings.openchamber.git.showGitignoredAria': 'Wyświetlaj pliki zignorowane przez Git', 'settings.openchamber.git.title': 'Preferencje Git', 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_backward.label': 'Przełącz ulubiony model wstecz', + 'settings.openchamber.keyboardShortcuts.action.open_model_selector.label': 'Otwórz wybór modelu', 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': 'Przełącz ulubiony model w przód', 'settings.openchamber.keyboardShortcuts.action.cycle_right_sidebar_tab.label': 'Przełącz zakładkę prawego paska bocznego', 'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label': 'Przełącz zakładkę usług', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 60738525..4aab66bc 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -365,12 +365,14 @@ export const settingsDict = { "settings.agents.sidebar.toast.agentExists": "Já existe um agente com este nome", "settings.agents.sidebar.toast.removeOldAfterRenameFailed": "Não foi possível excluir o agente antigo depois da renomeação", "settings.agents.sidebar.toast.renameFailed": "Não foi possível renomear o agente", + "settings.agents.sidebar.toast.agentRenamed": "Agente renomeado para \"{name}\"", "settings.agents.page.toast.permissionNameRequired": "É necessário informar o nome da permissão", "settings.agents.page.toast.created": "Agente criado com sucesso", "settings.agents.page.toast.updated": "Agente atualizado com sucesso", "settings.agents.page.toast.createFailed": "Não foi possível criar o agente", "settings.agents.page.toast.updateFailed": "Não foi possível atualizar o agente", "settings.agents.page.toast.saveUnexpectedError": "Ocorreu um erro ao salvar", + "settings.agents.page.toast.savedManualRestart": "Salvo no disco. Reinicie o servidor OpenCode conectado para aplicar as alterações.", "settings.agents.page.empty.title": "Selecione um agente da barra lateral", "settings.agents.page.empty.description": "ou crie um novo", "settings.agents.page.title.new": "Novo agente", @@ -926,6 +928,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.cycle_agent.label": "Alternar agente", "settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label": "Próximo modelo favorito", "settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_backward.label": "Modelo favorito anterior", + "settings.openchamber.keyboardShortcuts.action.open_model_selector.label": "Abrir seletor de modelo", "settings.openchamber.keyboardShortcuts.action.expand_input.label": "Expandir entrada", "settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label": "Abrir linha do tempo da conversa", "settings.projects.sidebar.total": "Total {count}", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 8d1dcef1..e62eda5e 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -365,12 +365,14 @@ export const settingsDict = { "settings.agents.sidebar.toast.agentExists": "Агент із такою назвою вже існує", "settings.agents.sidebar.toast.removeOldAfterRenameFailed": "Не вдалося видалити старого агента після перейменування", "settings.agents.sidebar.toast.renameFailed": "Не вдалося перейменувати агента", + "settings.agents.sidebar.toast.agentRenamed": "Агента перейменовано на \"{name}\"", "settings.agents.page.toast.permissionNameRequired": "Потрібна назва дозволу", "settings.agents.page.toast.created": "Агент створено успішно", "settings.agents.page.toast.updated": "Агент успішно оновлено", "settings.agents.page.toast.createFailed": "Не вдалося створити агента", "settings.agents.page.toast.updateFailed": "Не вдалося оновити агента", "settings.agents.page.toast.saveUnexpectedError": "Під час збереження сталася помилка", + "settings.agents.page.toast.savedManualRestart": "Збережено на диск. Перезапустіть підключений сервер OpenCode, щоб застосувати зміни.", "settings.agents.page.empty.title": "Виберіть агента на бічній панелі", "settings.agents.page.empty.description": "або створіть нового", "settings.agents.page.title.new": "Новий агент", @@ -926,6 +928,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.cycle_agent.label": "Перемкнути агента", "settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label": "Перемкнути улюблену модель вперед", "settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_backward.label": "Перемкнути улюблену модель назад", + "settings.openchamber.keyboardShortcuts.action.open_model_selector.label": "Відкрити вибір моделі", "settings.openchamber.keyboardShortcuts.action.expand_input.label": "Розгорнути введення", "settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label": "Відкрити хронологію розмови", "settings.projects.sidebar.total": "Усього {count}", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 9e2b9ccf..6d9ad39b 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -365,12 +365,14 @@ export const settingsDict = { 'settings.agents.sidebar.toast.agentExists': '已存在同名智能体', 'settings.agents.sidebar.toast.removeOldAfterRenameFailed': '重命名后移除旧智能体失败', 'settings.agents.sidebar.toast.renameFailed': '重命名智能体失败', + 'settings.agents.sidebar.toast.agentRenamed': '智能体已重命名为 "{name}"', 'settings.agents.page.toast.permissionNameRequired': '权限名称为必填项', 'settings.agents.page.toast.created': '智能体创建成功', 'settings.agents.page.toast.updated': '智能体更新成功', 'settings.agents.page.toast.createFailed': '创建智能体失败', 'settings.agents.page.toast.updateFailed': '更新智能体失败', 'settings.agents.page.toast.saveUnexpectedError': '保存时发生错误', + 'settings.agents.page.toast.savedManualRestart': '已保存到磁盘。请重启已连接的 OpenCode 服务器以应用更改。', 'settings.agents.page.empty.title': '从侧边栏选择一个智能体', 'settings.agents.page.empty.description': '或新建一个智能体', 'settings.agents.page.title.new': '新建智能体', @@ -926,6 +928,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': '轮换智能体', 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': '向前轮换收藏模型', 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_backward.label': '向后轮换收藏模型', + 'settings.openchamber.keyboardShortcuts.action.open_model_selector.label': '打开模型选择器', 'settings.openchamber.keyboardShortcuts.action.expand_input.label': '展开输入框', 'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': '打开对话时间线', 'settings.projects.sidebar.total': '总计 {count}', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index d5518674..953c7174 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -362,12 +362,14 @@ 'settings.agents.sidebar.toast.agentExists': '已存在同名 agent', 'settings.agents.sidebar.toast.removeOldAfterRenameFailed': '重新命名後移除舊 agent 失敗', 'settings.agents.sidebar.toast.renameFailed': '重新命名 agent 失敗', + 'settings.agents.sidebar.toast.agentRenamed': 'Agent 已重新命名為 "{name}"', 'settings.agents.page.toast.permissionNameRequired': '權限名稱為必填項', 'settings.agents.page.toast.created': 'Agent 建立成功', 'settings.agents.page.toast.updated': 'Agent 更新成功', 'settings.agents.page.toast.createFailed': '建立 agent 失敗', 'settings.agents.page.toast.updateFailed': '更新 agent 失敗', 'settings.agents.page.toast.saveUnexpectedError': '儲存時發生錯誤', + 'settings.agents.page.toast.savedManualRestart': '已儲存到磁碟。請重新啟動已連線的 OpenCode 伺服器以套用變更。', 'settings.agents.page.empty.title': '從側邊欄選擇一個 agent', 'settings.agents.page.empty.description': '或建立一個 agent', 'settings.agents.page.title.new': '新增 Agent', @@ -848,6 +850,7 @@ 'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': '輪換 agent', 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': '向前輪換收藏模型', 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_backward.label': '向後輪換收藏模型', + 'settings.openchamber.keyboardShortcuts.action.open_model_selector.label': '開啟模型選擇器', 'settings.openchamber.keyboardShortcuts.action.expand_input.label': '展開輸入方塊', 'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': '開啟對話時間軸', 'settings.projects.sidebar.total': '總計 {count}', diff --git a/packages/ui/src/lib/shortcuts.ts b/packages/ui/src/lib/shortcuts.ts index 5640372b..f955dbae 100644 --- a/packages/ui/src/lib/shortcuts.ts +++ b/packages/ui/src/lib/shortcuts.ts @@ -291,6 +291,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ defaultCombo: 'mod+shift+m', label: 'Open model selector', description: 'Open model selector while in chat', + customizable: true, }, { id: 'cycle_thinking_variant', diff --git a/packages/ui/src/stores/useAgentsStore.ts b/packages/ui/src/stores/useAgentsStore.ts index 29f3e6ef..9b642017 100644 --- a/packages/ui/src/stores/useAgentsStore.ts +++ b/packages/ui/src/stores/useAgentsStore.ts @@ -115,6 +115,17 @@ export interface AgentConfig { scope?: AgentScope; } +/** + * Result of an agent config mutation. + * `requiresManualRestart` is true when the change was persisted to disk but the + * connected (external) OpenCode server could not be reloaded by OpenChamber, so + * the user must restart that server before the change takes effect. + */ +export interface AgentMutationResult { + ok: boolean; + requiresManualRestart?: boolean; +} + // Extended Agent type for API properties not in SDK types export type AgentWithExtras = Agent & { native?: boolean; @@ -190,9 +201,9 @@ interface AgentsStore { setSelectedAgent: (name: string | null) => void; setAgentDraft: (draft: AgentDraft | null) => void; loadAgents: () => Promise; - createAgent: (config: AgentConfig) => Promise; - updateAgent: (name: string, config: Partial) => Promise; - deleteAgent: (name: string, scope?: AgentScope) => Promise; + createAgent: (config: AgentConfig) => Promise; + updateAgent: (name: string, config: Partial) => Promise; + deleteAgent: (name: string, scope?: AgentScope) => Promise; getAgentByName: (name: string) => Agent | undefined; // Returns only visible agents (excludes hidden internal agents) getVisibleAgents: () => Agent[]; @@ -361,8 +372,16 @@ export const useAgentsStore = create()( throw new Error(message); } - const needsReload = payload?.requiresReload ?? true; invalidateAgentsLoadCache(configDirectory); + + // External OpenCode server: persisted to disk but not reloaded. + // Skip the reload so the form keeps the just-saved values instead of + // reverting to the server's stale, startup-cached config. + if (payload?.requiresManualRestart) { + return { ok: true, requiresManualRestart: true }; + } + + const needsReload = payload?.requiresReload ?? true; if (needsReload) { requiresReload = true; await refreshAfterOpenCodeRestart({ @@ -371,17 +390,17 @@ export const useAgentsStore = create()( scopes: ["agents"], mode: "projects", }); - return true; + return { ok: true }; } const loaded = await get().loadAgents(); if (loaded) { emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE }); } - return loaded; + return { ok: loaded }; } catch (error) { console.error('Failed to create agent:', error); - return false; + return { ok: false }; } finally { if (!requiresReload) { finishConfigUpdate(); @@ -424,8 +443,16 @@ export const useAgentsStore = create()( throw new Error(message); } - const needsReload = payload?.requiresReload ?? true; invalidateAgentsLoadCache(configDirectory); + + // External OpenCode server: persisted to disk but not reloaded. + // Skip the reload so the form keeps the just-saved values instead of + // reverting to the server's stale, startup-cached config. + if (payload?.requiresManualRestart) { + return { ok: true, requiresManualRestart: true }; + } + + const needsReload = payload?.requiresReload ?? true; if (needsReload) { requiresReload = true; await refreshAfterOpenCodeRestart({ @@ -434,14 +461,14 @@ export const useAgentsStore = create()( scopes: ["agents"], mode: "projects", }); - return true; + return { ok: true }; } const loaded = await get().loadAgents(); if (loaded) { emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE }); } - return loaded; + return { ok: loaded }; } catch (error) { console.error('Failed to update agent:', error); throw error; @@ -475,8 +502,18 @@ export const useAgentsStore = create()( throw new Error(message); } - const needsReload = payload?.requiresReload ?? true; invalidateAgentsLoadCache(configDirectory); + + if (get().selectedAgentName === name) { + set({ selectedAgentName: null }); + } + + // External OpenCode server: persisted to disk but not reloaded. + if (payload?.requiresManualRestart) { + return { ok: true, requiresManualRestart: true }; + } + + const needsReload = payload?.requiresReload ?? true; if (needsReload) { requiresReload = true; await refreshAfterOpenCodeRestart({ @@ -485,7 +522,7 @@ export const useAgentsStore = create()( scopes: ["agents"], mode: "projects", }); - return true; + return { ok: true }; } const loaded = await get().loadAgents(); @@ -493,10 +530,7 @@ export const useAgentsStore = create()( emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE }); } - if (get().selectedAgentName === name) { - set({ selectedAgentName: null }); - } - return loaded; + return { ok: loaded }; } catch (error) { console.error('Failed to delete agent:', error); throw error; diff --git a/packages/web/server/lib/opencode/config-entity-routes.js b/packages/web/server/lib/opencode/config-entity-routes.js index 3d5886aa..7a87f7d5 100644 --- a/packages/web/server/lib/opencode/config-entity-routes.js +++ b/packages/web/server/lib/opencode/config-entity-routes.js @@ -26,6 +26,30 @@ export const registerConfigEntityRoutes = (app, dependencies) => { expandSnippets, } = dependencies; + // Build the response for a config mutation based on whether OpenCode actually + // reloaded the change. When connected to an external OpenCode server that + // OpenChamber cannot restart, the change is persisted to disk but the running + // server will not serve it until the user restarts that server. We must not + // report a clean "reloading" success in that case, otherwise the UI silently + // reverts the edit to the stale value on the next refresh. + const buildConfigMutationResponse = (refreshResult, { liveMessage, manualRestartMessage }) => { + if (refreshResult && refreshResult.external) { + return { + success: true, + requiresReload: false, + requiresManualRestart: true, + message: manualRestartMessage, + }; + } + + return { + success: true, + requiresReload: true, + message: liveMessage, + reloadDelayMs: clientReloadDelayMs, + }; + }; + const completeMcpMutation = async (res, action, name, applyChange) => { applyChange(); @@ -104,16 +128,14 @@ export const registerConfigEntityRoutes = (app, dependencies) => { console.log('[Server] Scope:', scope, 'Working directory:', directory); createAgent(agentName, config, directory, scope); - await refreshOpenCodeAfterConfigChange('agent creation', { + const refreshResult = await refreshOpenCodeAfterConfigChange('agent creation', { agentName }); - res.json({ - success: true, - requiresReload: true, - message: `Agent ${agentName} created successfully. Reloading interface…`, - reloadDelayMs: clientReloadDelayMs, - }); + res.json(buildConfigMutationResponse(refreshResult, { + liveMessage: `Agent ${agentName} created successfully. Reloading interface…`, + manualRestartMessage: `Agent ${agentName} saved. Restart your connected OpenCode server to apply the change.`, + })); } catch (error) { console.error('Failed to create agent:', error); res.status(500).json({ error: error.message || 'Failed to create agent' }); @@ -134,16 +156,14 @@ export const registerConfigEntityRoutes = (app, dependencies) => { console.log('[Server] Working directory:', directory); updateAgent(agentName, updates, directory); - await refreshOpenCodeAfterConfigChange('agent update'); + const refreshResult = await refreshOpenCodeAfterConfigChange('agent update'); console.log(`[Server] Agent ${agentName} updated successfully`); - res.json({ - success: true, - requiresReload: true, - message: `Agent ${agentName} updated successfully. Reloading interface…`, - reloadDelayMs: clientReloadDelayMs, - }); + res.json(buildConfigMutationResponse(refreshResult, { + liveMessage: `Agent ${agentName} updated successfully. Reloading interface…`, + manualRestartMessage: `Agent ${agentName} saved. Restart your connected OpenCode server to apply the change.`, + })); } catch (error) { console.error('[Server] Failed to update agent:', error); console.error('[Server] Error stack:', error.stack); @@ -161,14 +181,12 @@ export const registerConfigEntityRoutes = (app, dependencies) => { const scope = req.body?.scope; deleteAgent(agentName, directory, scope); - await refreshOpenCodeAfterConfigChange('agent deletion'); + const refreshResult = await refreshOpenCodeAfterConfigChange('agent deletion'); - res.json({ - success: true, - requiresReload: true, - message: `Agent ${agentName} deleted successfully. Reloading interface…`, - reloadDelayMs: clientReloadDelayMs, - }); + res.json(buildConfigMutationResponse(refreshResult, { + liveMessage: `Agent ${agentName} deleted successfully. Reloading interface…`, + manualRestartMessage: `Agent ${agentName} deleted. Restart your connected OpenCode server to apply the change.`, + })); } catch (error) { console.error('Failed to delete agent:', error); res.status(500).json({ error: error.message || 'Failed to delete agent' }); diff --git a/packages/web/server/lib/opencode/lifecycle.js b/packages/web/server/lib/opencode/lifecycle.js index ca1a82d5..d01a9fd3 100644 --- a/packages/web/server/lib/opencode/lifecycle.js +++ b/packages/web/server/lib/opencode/lifecycle.js @@ -753,12 +753,22 @@ export const createOpenCodeLifecycleRuntime = (deps) => { await restartOpenCode(); + // A managed OpenCode process is restarted (and thus re-reads config from + // disk) by restartOpenCode(). An external OpenCode server is NOT owned by + // OpenChamber: restartOpenCode() only re-probes its health, so the freshly + // written config is on disk but the running server keeps serving its old, + // startup-cached config until the user restarts it themselves. Report this + // honestly so callers don't claim the change is live. + const external = state.isExternalOpenCode === true; + try { await waitForOpenCodeReady(); state.isOpenCodeReady = true; state.openCodeNotReadySince = 0; - if (agentName) { + // Waiting for the agent to appear only makes sense when we actually + // reloaded config. An external server will never surface it here. + if (agentName && !external) { await waitForAgentPresence(agentName); } @@ -770,6 +780,8 @@ export const createOpenCodeLifecycleRuntime = (deps) => { console.error(`Failed to refresh OpenCode after ${reason}:`, error.message); throw error; } + + return { reloaded: !external, external }; }; const bootstrapOpenCodeAtStartup = async () => {