Merge remote-tracking branch 'origin/main' into port-2619

# Conflicts:
#	packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx
This commit is contained in:
Bohdan Triapitsyn
2026-08-29 00:26:49 +03:00
1318 changed files with 118125 additions and 30528 deletions
@@ -6,10 +6,10 @@ import { toast } from '@/components/ui';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { opencodeClient } from '@/lib/opencode/client';
import {
useAgentsStore,
getConfigDirectory,
type AgentWithExtras,
} from '@/stores/useAgentsStore';
import {
@@ -105,6 +105,9 @@ export const AgentPermissionsEditor: React.FC<AgentPermissionsEditorProps> = ({
const [reloadToken, setReloadToken] = React.useState(0);
const agentName = agent.name;
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
// --- Load the SOURCE permission map (the agent's own config file). ---
React.useEffect(() => {
@@ -113,7 +116,7 @@ export const AgentPermissionsEditor: React.FC<AgentPermissionsEditorProps> = ({
setLoadFailed(false);
void (async () => {
try {
const directory = getConfigDirectory();
const directory = settingsDirectory;
const query = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/agents/${encodeURIComponent(agentName)}/config${query}`, {
headers: {
@@ -136,14 +139,14 @@ export const AgentPermissionsEditor: React.FC<AgentPermissionsEditorProps> = ({
return () => {
cancelled = true;
};
}, [agentName, reloadToken]);
}, [agentName, reloadToken, settingsDirectory]);
// --- Known tool ids for the key list (display only). ---
React.useEffect(() => {
let cancelled = false;
void (async () => {
try {
const ids = await opencodeClient.listToolIds({ directory: getConfigDirectory() });
const ids = await opencodeClient.listToolIds({ directory: settingsDirectory });
if (!cancelled && Array.isArray(ids)) {
setToolIds(ids.filter((id) => typeof id === 'string' && !FOLDED_TOOL_IDS.has(id)));
}
@@ -154,7 +157,7 @@ export const AgentPermissionsEditor: React.FC<AgentPermissionsEditorProps> = ({
return () => {
cancelled = true;
};
}, [agentName]);
}, [agentName, settingsDirectory]);
// --- Effective rules from the resolved view (read-only hints). ---
const effectiveRules = React.useMemo<EffectiveRule[]>(() => {
@@ -4,7 +4,8 @@ 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 AgentMutationResult, type AgentScope } from '@/stores/useAgentsStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { selectAgentsForDirectory, useAgentsStore, type AgentConfig, type AgentMutationResult, type AgentScope } from '@/stores/useAgentsStore';
import { useShallow } from 'zustand/react/shallow';
import { ModelSelector } from './ModelSelector';
import { useI18n } from '@/lib/i18n';
@@ -60,7 +61,6 @@ export const AgentsPage: React.FC = () => {
getAgentByName,
createAgent,
updateAgent,
agents,
agentDraft,
setAgentDraft,
} = useAgentsStore(useShallow((s) => ({
@@ -68,12 +68,15 @@ export const AgentsPage: React.FC = () => {
getAgentByName: s.getAgentByName,
createAgent: s.createAgent,
updateAgent: s.updateAgent,
agents: s.agents,
agentDraft: s.agentDraft,
setAgentDraft: s.setAgentDraft,
})));
const selectedAgent = selectedAgentName ? getAgentByName(selectedAgentName) : null;
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const agents = useAgentsStore((state) => selectAgentsForDirectory(state, settingsDirectory));
const selectedAgent = selectedAgentName ? getAgentByName(selectedAgentName, settingsDirectory) : null;
const isNewAgent = Boolean(agentDraft && agentDraft.name === selectedAgentName && !selectedAgent);
const [draftName, setDraftName] = React.useState('');
@@ -232,17 +235,19 @@ export const AgentsPage: React.FC = () => {
let result: AgentMutationResult;
if (isNewAgent) {
result = await createAgent(config);
result = await createAgent(config, settingsDirectory);
if (result.ok) {
setAgentDraft(null); // Clear draft after successful creation
}
} else {
result = await updateAgent(agentName, config);
result = await updateAgent(agentName, config, settingsDirectory);
}
if (result.ok) {
if (result.requiresManualRestart) {
toast.warning(t('settings.agents.page.toast.savedManualRestart'));
} else if (result.restartDeferred) {
toast.success(t('settings.view.pendingRestart.saved'));
} else {
toast.success(isNewAgent ? t('settings.agents.page.toast.created') : t('settings.agents.page.toast.updated'));
}
@@ -445,7 +450,7 @@ export const AgentsPage: React.FC = () => {
inputMode="decimal"
placeholder="—"
emptyLabel="—"
className="w-16"
className="w-20"
/>
{temperature !== undefined && (
<Button
@@ -483,7 +488,7 @@ export const AgentsPage: React.FC = () => {
inputMode="decimal"
placeholder="—"
emptyLabel="—"
className="w-16"
className="w-20"
/>
{topP !== undefined && (
<Button
@@ -18,7 +18,8 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { useAgentsStore, isAgentBuiltIn, isAgentHidden, type AgentScope, type AgentDraft } from '@/stores/useAgentsStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { selectAgentsForDirectory, useAgentsStore, isAgentBuiltIn, isAgentHidden, type AgentScope, type AgentDraft } from '@/stores/useAgentsStore';
import { useShallow } from 'zustand/react/shallow';
import { cn } from '@/lib/utils';
import type { Agent } from '@opencode-ai/sdk/v2';
@@ -113,7 +114,6 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
const {
selectedAgentName,
agents,
setSelectedAgent,
setAgentDraft,
createAgent,
@@ -121,7 +121,6 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
loadAgents,
} = useAgentsStore(useShallow((s) => ({
selectedAgentName: s.selectedAgentName,
agents: s.agents,
setSelectedAgent: s.setSelectedAgent,
setAgentDraft: s.setAgentDraft,
createAgent: s.createAgent,
@@ -129,9 +128,14 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
loadAgents: s.loadAgents,
})));
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const agents = useAgentsStore((state) => selectAgentsForDirectory(state, settingsDirectory));
React.useEffect(() => {
loadAgents();
}, [loadAgents]);
void loadAgents(settingsDirectory);
}, [loadAgents, settingsDirectory]);
const bgClass = 'bg-background';
@@ -183,11 +187,13 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
setIsConfirmActionPending(true);
try {
const result = await deleteAgent(confirmActionAgent.name, (confirmActionAgent as Agent & { scope?: AgentScope }).scope);
const result = await deleteAgent(confirmActionAgent.name, (confirmActionAgent as Agent & { scope?: AgentScope }).scope, settingsDirectory);
if (result.ok) {
if (result.requiresManualRestart) {
toast.warning(t('settings.agents.page.toast.savedManualRestart'));
} else if (result.restartDeferred) {
toast.success(t('settings.view.pendingRestart.saved'));
} else if (confirmActionType === 'delete') {
toast.success(t('settings.agents.sidebar.toast.agentDeleted', { name: confirmActionAgent.name }));
} else {
@@ -244,6 +250,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
disable: draftAgent.disable,
});
setSelectedAgent(newName);
onItemSelect?.();
};
@@ -289,11 +296,11 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
permission: rulesetToPermissionConfig(renameDialogAgent.permission),
disable: renameExt.disable,
scope: renameExt.scope,
});
}, settingsDirectory);
if (createResult.ok) {
// Delete old agent
const deleteResult = await deleteAgent(renameDialogAgent.name, renameExt.scope);
const deleteResult = await deleteAgent(renameDialogAgent.name, renameExt.scope, settingsDirectory);
if (deleteResult.ok) {
if (createResult.requiresManualRestart || deleteResult.requiresManualRestart) {
toast.warning(t('settings.agents.page.toast.savedManualRestart'));
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
import { useI18n, type I18nKey } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { reportSettingsSaveState } from '@/lib/persistence';
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
import {
@@ -20,7 +21,7 @@ import {
} from '@/lib/responseStyle';
import type { DesktopSettings } from '@/lib/desktop';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
import { noteDeferredRestartFromPayload, recordDeferredOpenCodeRestart } from '@/lib/opencode/deferredRestart';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import {
SettingsSection,
@@ -242,13 +243,20 @@ export const BehaviorPage: React.FC = () => {
throw new Error(await readApiError(response, t('settings.behavior.page.toast.saveFailed')));
}
const payload = await response.json().catch(() => null);
const deferred = noteDeferredRestartFromPayload(payload, 'behavior', { id: 'agents-md' });
await saveBehaviorSetting({
globalBehaviorPrompt: content,
}, t('settings.behavior.page.toast.saveFailed'));
setPrompt(content);
setInitialPrompt(content);
toast.success(t('settings.behavior.page.toast.saved'));
toast.success(
deferred
? t('settings.view.pendingRestart.saved')
: t('settings.behavior.page.toast.saved'),
);
} catch (error) {
console.error('Failed to save behavior:', error);
const message = error instanceof Error ? error.message : t('settings.behavior.page.toast.saveFailed');
@@ -266,19 +274,8 @@ export const BehaviorPage: React.FC = () => {
t('settings.behavior.page.toast.saveFailed'),
);
setInitialOptimizeSystemPrompt(optimizeSystemPrompt);
} catch (error) {
const message = error instanceof Error ? error.message : t('settings.behavior.page.toast.saveFailed');
toast.error(message);
setIsApplyingPromptOptimization(false);
return;
}
try {
await reloadOpenCodeConfiguration({
message: t('settings.behavior.page.systemPromptOptimization.restarting'),
mode: 'projects',
scopes: ['all'],
});
recordDeferredOpenCodeRestart('behavior', { id: 'optimize-system-prompt' });
toast.success(t('settings.view.pendingRestart.saved'));
} catch (error) {
const message = error instanceof Error ? error.message : t('settings.behavior.page.toast.saveFailed');
toast.error(message);
@@ -317,7 +314,7 @@ export const BehaviorPage: React.FC = () => {
>
{isApplyingPromptOptimization
? t('settings.common.actions.saving')
: t('settings.openchamber.opencodeCli.actions.saveAndReload')}
: t('settings.common.actions.saveChanges')}
</Button>
</SettingsSection>
)}
@@ -379,7 +376,7 @@ export const BehaviorPage: React.FC = () => {
onValueChange={(value) => setResponseStylePreset(value)}
disabled={isLoading || !responseStyleEnabled}
>
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}>
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={cn(SETTINGS_SELECT_ROW_TRIGGER_CLASS, 'max-w-72')}>
<SelectValue>
{(value) => {
if (value === 'custom') return t('settings.behavior.page.responseStyle.option.custom');
@@ -3,7 +3,9 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
import { useCommandsStore, type CommandConfig, type CommandScope } from '@/stores/useCommandsStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { selectCommandsForDirectory, useCommandsStore, type CommandConfig, type CommandScope } from '@/stores/useCommandsStore';
import { usePendingOpenCodeRestartStore } from '@/stores/usePendingOpenCodeRestartStore';
import { useShallow } from 'zustand/react/shallow';
import { ModelSelector } from '../agents/ModelSelector';
import { AgentSelector } from './AgentSelector';
@@ -32,7 +34,6 @@ export const CommandsPage: React.FC = () => {
getCommandByName,
createCommand,
updateCommand,
commands,
commandDraft,
setCommandDraft,
} = useCommandsStore(useShallow((s) => ({
@@ -40,12 +41,15 @@ export const CommandsPage: React.FC = () => {
getCommandByName: s.getCommandByName,
createCommand: s.createCommand,
updateCommand: s.updateCommand,
commands: s.commands,
commandDraft: s.commandDraft,
setCommandDraft: s.setCommandDraft,
})));
const selectedCommand = selectedCommandName ? getCommandByName(selectedCommandName) : null;
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const commands = useCommandsStore((state) => selectCommandsForDirectory(state, settingsDirectory));
const selectedCommand = selectedCommandName ? getCommandByName(selectedCommandName, settingsDirectory) : null;
const isNewCommand = Boolean(commandDraft && commandDraft.name === selectedCommandName && !selectedCommand);
const [draftName, setDraftName] = React.useState('');
@@ -161,16 +165,25 @@ export const CommandsPage: React.FC = () => {
let success: boolean;
if (isNewCommand) {
success = await createCommand(config);
success = await createCommand(config, settingsDirectory);
if (success) {
setCommandDraft(null);
}
} else {
success = await updateCommand(commandName, config);
success = await updateCommand(commandName, config, settingsDirectory);
}
if (success) {
toast.success(isNewCommand ? t('settings.commands.page.toast.created') : t('settings.commands.page.toast.updated'));
const deferred = usePendingOpenCodeRestartStore.getState().changes.some(
(change) => change.scope === 'commands' && change.id.startsWith(`commands:${commandName}:`),
);
toast.success(
deferred
? t('settings.view.pendingRestart.saved')
: isNewCommand
? t('settings.commands.page.toast.created')
: t('settings.commands.page.toast.updated'),
);
} else {
toast.error(isNewCommand ? t('settings.commands.page.toast.createFailed') : t('settings.commands.page.toast.updateFailed'));
}
@@ -18,8 +18,9 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { useCommandsStore, isCommandBuiltIn, type Command } from '@/stores/useCommandsStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { selectCommandsForDirectory, useCommandsStore, isCommandBuiltIn, type Command } from '@/stores/useCommandsStore';
import { selectSkillsForDirectory, useSkillsStore } from '@/stores/useSkillsStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { useShallow } from 'zustand/react/shallow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { cn } from '@/lib/utils';
@@ -43,7 +44,6 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
const {
selectedCommandName,
commands,
setSelectedCommand,
setCommandDraft,
createCommand,
@@ -51,20 +51,23 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
loadCommands,
} = useCommandsStore(useShallow((s) => ({
selectedCommandName: s.selectedCommandName,
commands: s.commands,
setSelectedCommand: s.setSelectedCommand,
setCommandDraft: s.setCommandDraft,
createCommand: s.createCommand,
deleteCommand: s.deleteCommand,
loadCommands: s.loadCommands,
})));
const skills = useSkillsStore((s) => s.skills);
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const commands = useCommandsStore((state) => selectCommandsForDirectory(state, settingsDirectory));
const skills = useSkillsStore((state) => selectSkillsForDirectory(state, settingsDirectory));
const loadSkills = useSkillsStore((s) => s.loadSkills);
React.useEffect(() => {
loadCommands();
loadSkills();
}, [loadCommands, loadSkills]);
void loadCommands(settingsDirectory);
void loadSkills(settingsDirectory);
}, [loadCommands, loadSkills, settingsDirectory]);
const skillNames = React.useMemo(() => new Set(skills.map((skill) => skill.name)), [skills]);
const commandOnlyItems = React.useMemo(
@@ -131,7 +134,7 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
}
setIsConfirmActionPending(true);
const success = await deleteCommand(confirmActionCommand.name);
const success = await deleteCommand(confirmActionCommand.name, settingsDirectory);
if (success) {
if (confirmActionType === 'delete') {
@@ -204,11 +207,11 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
template: renameDialogCommand.template,
agent: renameDialogCommand.agent,
model: renameDialogCommand.model,
});
}, settingsDirectory);
if (success) {
// Delete old command
const deleteSuccess = await deleteCommand(renameDialogCommand.name);
const deleteSuccess = await deleteCommand(renameDialogCommand.name, settingsDirectory);
if (deleteSuccess) {
toast.success(`Command renamed to "${sanitizedName}"`);
setSelectedCommand(sanitizedName);
@@ -0,0 +1,42 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import { SETTINGS_DESCRIPTION_CLASS } from '@/components/sections/shared/SettingsSection';
import { useI18n } from '@/lib/i18n';
import { ThirdPartyIntegrationsSection } from './ThirdPartyIntegrationsSection';
interface IntegrationsPageProps {
onOpenProviderSetup: (providerId: string) => Promise<boolean>;
onOpenPluginManager: () => void;
}
export const IntegrationsPage: React.FC<IntegrationsPageProps> = ({
onOpenProviderSetup,
onOpenPluginManager,
}) => {
const { t } = useI18n();
return (
<SettingsPageLayout
title={t('settings.page.integrations.title')}
description={(
<div className="space-y-3">
<p className={SETTINGS_DESCRIPTION_CLASS}>{t('settings.page.integrations.description')}</p>
<div role="alert" className="flex items-start gap-2 rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3">
<Icon name="error-warning" className="mt-0.5 size-4 shrink-0 text-[var(--status-warning)]" />
<p className="typography-meta text-[var(--status-warning)]">
{t('settings.integrations.experimentalWarning')}
</p>
</div>
</div>
)}
showSaveStatus={false}
>
<ThirdPartyIntegrationsSection
divider={false}
onOpenProviderSetup={onOpenProviderSetup}
onOpenPluginManager={onOpenPluginManager}
/>
</SettingsPageLayout>
);
};
@@ -0,0 +1,442 @@
import React from 'react';
import { useShallow } from 'zustand/react/shallow';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { toast } from '@/components/ui';
import { Icon } from '@/components/icon/Icon';
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useI18n } from '@/lib/i18n';
import { openExternalUrl } from '@/lib/url';
import { cn } from '@/lib/utils';
import {
usePluginsStore,
type PluginMutationResult,
} from '@/stores/usePluginsStore';
import { usePendingOpenCodeRestartStore } from '@/stores/usePendingOpenCodeRestartStore';
import {
getCatalogPluginPrimaryAction,
getCatalogPluginPresentation,
getCatalogPluginState,
getLatestNpmSpec,
THIRD_PARTY_PLUGINS,
type ThirdPartyPluginDefinition,
} from './thirdPartyPlugins';
type PendingAction = 'install' | 'update' | 'setup' | 'remove';
type RemoveTarget = ThirdPartyPluginDefinition | null;
interface ThirdPartyIntegrationsSectionProps {
divider?: boolean;
onOpenProviderSetup: (providerId: string) => Promise<boolean>;
onOpenPluginManager: () => void;
}
const requiresRestart = (result: PluginMutationResult): boolean =>
result.restartDeferred === true
|| result.requiresManualRestart === true
|| result.reloadFailed === true;
export const ThirdPartyIntegrationsSection: React.FC<ThirdPartyIntegrationsSectionProps> = ({
divider = true,
onOpenProviderSetup,
onOpenPluginManager,
}) => {
const { t } = useI18n();
const {
entries,
registryInfo,
loadPlugins,
loadRegistryInfo,
createEntry,
updateEntry,
deleteEntry,
} = usePluginsStore(
useShallow((state) => ({
entries: state.entries,
registryInfo: state.registryInfo,
loadPlugins: state.loadPlugins,
loadRegistryInfo: state.loadRegistryInfo,
createEntry: state.createEntry,
updateEntry: state.updateEntry,
deleteEntry: state.deleteEntry,
})),
);
const [registryLoadFailed, setRegistryLoadFailed] = React.useState(false);
const [pendingAction, setPendingAction] = React.useState<{
pluginId: string;
action: PendingAction;
} | null>(null);
const [restartRequiredIds, setRestartRequiredIds] = React.useState<ReadonlySet<string>>(
() => new Set(),
);
const [providerUnavailableIds, setProviderUnavailableIds] = React.useState<ReadonlySet<string>>(
() => new Set(),
);
const [removeTarget, setRemoveTarget] = React.useState<RemoveTarget>(null);
const [openPluginIds, setOpenPluginIds] = React.useState<ReadonlySet<string>>(() => new Set());
const refresh = React.useCallback(async () => {
const pluginsLoaded = await loadPlugins({ force: true });
if (!pluginsLoaded) {
setRegistryLoadFailed(true);
return;
}
const latestEntries = usePluginsStore.getState().entries;
const specs = new Set(THIRD_PARTY_PLUGINS.map((plugin) => plugin.packageName));
for (const entry of latestEntries) {
if (THIRD_PARTY_PLUGINS.some((plugin) => entry.spec === plugin.packageName || entry.spec.startsWith(`${plugin.packageName}@`))) {
specs.add(entry.spec);
}
}
const registryLoaded = await loadRegistryInfo({ specs: [...specs], force: true });
setRegistryLoadFailed(!registryLoaded);
}, [loadPlugins, loadRegistryInfo]);
React.useEffect(() => {
void refresh();
}, [refresh]);
const pendingPluginRestartCount = usePendingOpenCodeRestartStore(
(state) => state.changes.filter((change) => change.scope === 'plugins').length,
);
const isApplyingRestart = usePendingOpenCodeRestartStore((state) => state.isApplying);
const previousPluginRestartCountRef = React.useRef(pendingPluginRestartCount);
// When deferred plugin restarts are applied (pending plugins scope clears), drop
// local restart/unavailable flags and reload so statuses update immediately.
React.useEffect(() => {
const previousCount = previousPluginRestartCountRef.current;
previousPluginRestartCountRef.current = pendingPluginRestartCount;
if (isApplyingRestart) {
return;
}
if (previousCount <= 0 || pendingPluginRestartCount > 0) {
return;
}
setRestartRequiredIds(new Set());
setProviderUnavailableIds(new Set());
void refresh();
}, [isApplyingRestart, pendingPluginRestartCount, refresh]);
const setRestartRequired = React.useCallback((pluginId: string, required: boolean) => {
setRestartRequiredIds((current) => {
const next = new Set(current);
if (required) next.add(pluginId);
else next.delete(pluginId);
return next;
});
}, []);
const setProviderUnavailable = React.useCallback((pluginId: string, unavailable: boolean) => {
setProviderUnavailableIds((current) => {
const next = new Set(current);
if (unavailable) next.add(pluginId);
else next.delete(pluginId);
return next;
});
}, []);
const runMutation = React.useCallback(async (
plugin: ThirdPartyPluginDefinition,
action: Exclude<PendingAction, 'setup'>,
run: () => Promise<PluginMutationResult>,
) => {
setPendingAction({ pluginId: plugin.id, action });
try {
const result = await run();
if (!result.ok) {
toast.error(t('settings.integrations.thirdParty.toast.actionFailed'));
return;
}
setProviderUnavailable(plugin.id, false);
const restartNeeded = requiresRestart(result);
setRestartRequired(plugin.id, restartNeeded);
const toastOptions = restartNeeded
? { description: t('settings.integrations.thirdParty.toast.restartRequired') }
: undefined;
if (action === 'install') {
toast.success(t('settings.integrations.thirdParty.toast.installed', { name: t(plugin.nameKey) }), toastOptions);
} else if (action === 'update') {
toast.success(t('settings.integrations.thirdParty.toast.updated', { name: t(plugin.nameKey) }), toastOptions);
} else {
toast.success(t('settings.integrations.thirdParty.toast.removed', { name: t(plugin.nameKey) }), toastOptions);
}
await refresh();
} finally {
setPendingAction(null);
}
}, [refresh, setProviderUnavailable, setRestartRequired, t]);
const handlePrimaryAction = React.useCallback(async (plugin: ThirdPartyPluginDefinition) => {
const state = getCatalogPluginState(entries, plugin.packageName, registryInfo);
const action = getCatalogPluginPrimaryAction(state, plugin.packageName);
if (action === 'manage') {
onOpenPluginManager();
return;
}
if (action === 'setup') {
setPendingAction({ pluginId: plugin.id, action });
try {
const opened = await onOpenProviderSetup(plugin.providerId);
setProviderUnavailable(plugin.id, !opened);
if (!opened) {
toast.error(t('settings.integrations.thirdParty.toast.providerUnavailable'));
}
} finally {
setPendingAction(null);
}
return;
}
const latestSpec = getLatestNpmSpec(plugin.packageName, state.registry);
if (!latestSpec) {
setRegistryLoadFailed(true);
return;
}
if (action === 'install') {
await runMutation(plugin, 'install', () => createEntry({ spec: latestSpec, scope: 'user' }));
return;
}
if (state.userEntry) {
await runMutation(plugin, 'update', () => updateEntry(state.userEntry!.id, { spec: latestSpec }));
}
}, [createEntry, entries, onOpenPluginManager, onOpenProviderSetup, registryInfo, runMutation, setProviderUnavailable, t, updateEntry]);
const handleRemove = React.useCallback(async () => {
const plugin = removeTarget;
if (!plugin) return;
const state = getCatalogPluginState(entries, plugin.packageName, registryInfo);
if (!state.userEntry || state.userEntryIsAmbiguous) {
setRemoveTarget(null);
onOpenPluginManager();
return;
}
setRemoveTarget(null);
await runMutation(plugin, 'remove', () => deleteEntry(state.userEntry!.id));
}, [deleteEntry, entries, onOpenPluginManager, registryInfo, removeTarget, runMutation]);
const setPluginOpen = React.useCallback((pluginId: string, open: boolean) => {
setOpenPluginIds((current) => {
const next = new Set(current);
if (open) next.add(pluginId);
else next.delete(pluginId);
return next;
});
}, []);
const renderPlugin = (plugin: ThirdPartyPluginDefinition) => {
const state = getCatalogPluginState(entries, plugin.packageName, registryInfo);
const primaryAction = getCatalogPluginPrimaryAction(state, plugin.packageName);
const latestSpec = getLatestNpmSpec(plugin.packageName, state.registry);
const isPending = pendingAction?.pluginId === plugin.id;
const isRestartRequired = restartRequiredIds.has(plugin.id);
const isProviderUnavailable = providerUnavailableIds.has(plugin.id);
const registryUnavailable = registryLoadFailed || state.registry?.kind === 'npm-network';
const actionDisabled = isPending
|| isRestartRequired
|| ((primaryAction === 'install' || primaryAction === 'update') && (registryUnavailable || !latestSpec));
const presentation = getCatalogPluginPresentation(state, {
registryUnavailable,
restartRequired: isRestartRequired,
providerUnavailable: isProviderUnavailable,
});
let status: string;
switch (presentation.status) {
case 'installed-version':
status = presentation.latestVersion
? t('settings.integrations.thirdParty.status.installedVersion', {
version: presentation.latestVersion,
})
: t('settings.integrations.thirdParty.status.installed');
break;
case 'update-available':
status = presentation.latestVersion
? t('settings.integrations.thirdParty.status.updateAvailable', {
version: presentation.latestVersion,
})
: t('settings.integrations.thirdParty.status.unpinned');
break;
case 'not-installed':
status = t('settings.integrations.thirdParty.status.notInstalled');
break;
case 'installed':
status = t('settings.integrations.thirdParty.status.installed');
break;
case 'unpinned':
status = t('settings.integrations.thirdParty.status.unpinned');
break;
case 'ambiguous':
status = t('settings.integrations.thirdParty.status.ambiguous');
break;
case 'restart-required':
status = t('settings.integrations.thirdParty.status.restartRequired');
break;
case 'registry-unavailable':
status = t('settings.integrations.thirdParty.status.registryUnavailable');
break;
case 'provider-unavailable':
status = t('settings.integrations.thirdParty.status.providerUnavailable');
break;
}
const statusClassName = presentation.status === 'installed-version'
? 'bg-[var(--status-success)]/15 text-[var(--status-success)]'
: presentation.status === 'update-available'
|| presentation.status === 'ambiguous'
|| presentation.status === 'restart-required'
|| presentation.status === 'registry-unavailable'
|| presentation.status === 'provider-unavailable'
? 'bg-[var(--status-warning)]/15 text-[var(--status-warning)]'
: 'bg-[var(--surface-muted)] text-muted-foreground';
const primaryLabel = {
install: t('settings.integrations.thirdParty.actions.install'),
update: t('settings.integrations.thirdParty.actions.update'),
setup: t('settings.integrations.thirdParty.actions.setup'),
manage: t('settings.integrations.thirdParty.actions.managePlugins'),
}[primaryAction];
const open = openPluginIds.has(plugin.id);
return (
<Collapsible
key={plugin.id}
open={open}
onOpenChange={(nextOpen) => setPluginOpen(plugin.id, nextOpen)}
>
<div
data-settings-item={`integrations.third-party.${plugin.id}`}
className="overflow-hidden rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)]"
>
<CollapsibleTrigger
className="flex w-full min-w-0 items-center gap-3 px-4 py-3 text-left hover:bg-[var(--interactive-hover)]/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--interactive-focus-ring)]"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[var(--surface-muted)]">
<Icon name={plugin.icon} className={cn('size-5', plugin.brandClassName)} />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-foreground">{t(plugin.nameKey)}</div>
<p className="mt-0.5 line-clamp-1 text-xs leading-snug text-muted-foreground">
{t(plugin.descriptionKey)}
</p>
</div>
<span
aria-live="polite"
className={cn(
'max-w-36 shrink-0 truncate rounded-full px-2 py-0.5 text-[10px] font-medium',
statusClassName,
)}
>
{status}
</span>
<Icon
name="arrow-down-s"
className={cn(
'size-4 shrink-0 text-muted-foreground transition-transform duration-150 ease-out motion-reduce:transition-none',
open && 'rotate-180',
)}
/>
</CollapsibleTrigger>
<CollapsibleContent className="border-t border-[var(--interactive-border)] px-4 py-4">
<div className="space-y-3">
{state.projectEntries.length > 0 ? (
<p className="text-xs text-muted-foreground">
{t('settings.integrations.thirdParty.status.projectInstalled')}
</p>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
variant={primaryAction === 'manage' ? 'outline' : 'default'}
onClick={() => void handlePrimaryAction(plugin)}
disabled={actionDisabled}
>
{isPending ? (
<Icon name="loader-4" className="size-3.5 animate-spin" />
) : primaryAction === 'setup' ? (
<Icon name="plug-2" className="size-3.5" />
) : null}
{primaryLabel}
</Button>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => void openExternalUrl(plugin.homepage)}
>
<Icon name="external-link" className="size-3.5" />
{t('settings.integrations.thirdParty.actions.docs')}
</Button>
{state.userEntry && !state.userEntryIsAmbiguous ? (
<Button
type="button"
size="sm"
variant="destructive"
onClick={() => setRemoveTarget(plugin)}
disabled={isPending}
>
<Icon name="delete-bin" className="size-3.5" />
{t('settings.integrations.thirdParty.actions.remove')}
</Button>
) : null}
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
);
};
return (
<>
<SettingsSection
title={t('settings.integrations.thirdParty.title')}
info={t('settings.integrations.thirdParty.info')}
divider={divider}
settingsItem="integrations.third-party"
contentClassName="space-y-3"
>
{THIRD_PARTY_PLUGINS.map(renderPlugin)}
</SettingsSection>
<Dialog open={removeTarget !== null} onOpenChange={(open) => !open && setRemoveTarget(null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('settings.integrations.thirdParty.dialog.remove.title')}</DialogTitle>
<DialogDescription>
{t('settings.integrations.thirdParty.dialog.remove.description', {
name: removeTarget ? t(removeTarget.nameKey) : '',
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" size="sm" variant="ghost" onClick={() => setRemoveTarget(null)}>
{t('settings.common.actions.cancel')}
</Button>
<Button type="button" size="sm" variant="destructive" onClick={() => void handleRemove()}>
{t('settings.integrations.thirdParty.actions.remove')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
@@ -0,0 +1,213 @@
import { describe, expect, test } from 'bun:test';
import type { PluginEntry, RegistryResult } from '@/stores/usePluginsStore';
import * as thirdPartyCatalog from './thirdPartyPlugins';
import {
getCatalogPluginState,
getCatalogPluginPrimaryAction,
getLatestNpmSpec,
specMatchesPackage,
} from './thirdPartyPlugins';
type CatalogPresentationStatus =
| 'not-installed'
| 'installed'
| 'installed-version'
| 'update-available'
| 'unpinned'
| 'ambiguous'
| 'restart-required'
| 'registry-unavailable'
| 'provider-unavailable';
type GetCatalogPluginPresentation = (
state: ReturnType<typeof getCatalogPluginState>,
options?: {
registryUnavailable?: boolean;
restartRequired?: boolean;
providerUnavailable?: boolean;
},
) => {
status: CatalogPresentationStatus;
latestVersion: string | null;
};
const getCatalogPluginPresentation = (
thirdPartyCatalog as unknown as {
getCatalogPluginPresentation?: GetCatalogPluginPresentation;
}
).getCatalogPluginPresentation;
const claudePackage = '@openchamber/opencode-claude';
const entry = (spec: string, scope: PluginEntry['scope'] = 'user'): PluginEntry => ({
id: `config:${scope}:${spec}`,
spec,
scope,
kind: 'config',
parsedKind: 'npm',
});
const registry = (spec: string, currentVersion: string | null, latestVersion = '0.7.0'): RegistryResult => ({
kind: 'npm-ok',
spec,
name: claudePackage,
currentVersion,
latestVersion,
versions: ['0.6.0', latestVersion],
hasUpdate: currentVersion !== null && currentVersion !== latestVersion,
});
describe('third-party plugin catalog helpers', () => {
test('derives compact-card status with explicit transient-state priority', () => {
expect(typeof getCatalogPluginPresentation).toBe('function');
if (!getCatalogPluginPresentation) return;
const notInstalled = getCatalogPluginState([], claudePackage, {});
expect(getCatalogPluginPresentation(notInstalled)).toEqual({
status: 'not-installed',
latestVersion: null,
});
const current = getCatalogPluginState(
[entry(`${claudePackage}@0.7.0`)],
claudePackage,
{ [`${claudePackage}@0.7.0`]: registry(`${claudePackage}@0.7.0`, '0.7.0') },
);
expect(getCatalogPluginPresentation(current)).toEqual({
status: 'installed-version',
latestVersion: '0.7.0',
});
const outdated = getCatalogPluginState(
[entry(`${claudePackage}@0.6.0`)],
claudePackage,
{ [`${claudePackage}@0.6.0`]: registry(`${claudePackage}@0.6.0`, '0.6.0') },
);
expect(getCatalogPluginPresentation(outdated)).toEqual({
status: 'update-available',
latestVersion: '0.7.0',
});
expect(getCatalogPluginPresentation(outdated, { registryUnavailable: true })).toEqual({
status: 'registry-unavailable',
latestVersion: '0.7.0',
});
expect(getCatalogPluginPresentation(outdated, { providerUnavailable: true })).toEqual({
status: 'provider-unavailable',
latestVersion: '0.7.0',
});
expect(getCatalogPluginPresentation(outdated, {
providerUnavailable: true,
restartRequired: true,
})).toEqual({
status: 'restart-required',
latestVersion: '0.7.0',
});
const ambiguous = getCatalogPluginState(
[entry(claudePackage), entry(`${claudePackage}@0.6.0`)],
claudePackage,
{},
);
expect(getCatalogPluginPresentation(ambiguous)).toEqual({
status: 'ambiguous',
latestVersion: null,
});
});
test('matches only a package or its versioned spec', () => {
expect(specMatchesPackage(claudePackage, claudePackage)).toBe(true);
expect(specMatchesPackage(`${claudePackage}@0.6.0`, claudePackage)).toBe(true);
expect(specMatchesPackage('@openchamber/opencode-claude-extra@0.6.0', claudePackage)).toBe(false);
});
test('points catalog plugins at the OpenChamber GitHub and npm packages', () => {
expect(thirdPartyCatalog.THIRD_PARTY_PLUGINS.map((plugin) => ({
id: plugin.id,
packageName: plugin.packageName,
homepage: plugin.homepage,
}))).toEqual([
{
id: 'opencode-claude',
packageName: '@openchamber/opencode-claude',
homepage: 'https://github.com/openchamber/opencode-claude',
},
{
id: 'opencode-cursor-oauth',
packageName: '@openchamber/opencode-cursor',
homepage: 'https://github.com/openchamber/opencode-cursor',
},
]);
});
test('uses the configured user entry and its registry result', () => {
const installed = entry(`${claudePackage}@0.6.0`);
const state = getCatalogPluginState(
[installed],
claudePackage,
{ [installed.spec]: registry(installed.spec, '0.6.0') },
);
expect(state.userEntry).toEqual(installed);
expect(state.userEntryIsAmbiguous).toBe(false);
expect(state.projectEntries).toEqual([]);
expect(state.registry).toEqual(registry(installed.spec, '0.6.0'));
});
test('does not choose an entry when multiple user specs would make a mutation ambiguous', () => {
const state = getCatalogPluginState(
[entry(claudePackage), entry(`${claudePackage}@0.6.0`), entry(claudePackage, 'project')],
claudePackage,
{},
);
expect(state.userEntry).toBeNull();
expect(state.userEntryIsAmbiguous).toBe(true);
expect(state.projectEntries).toHaveLength(1);
});
test('returns an exact latest spec only from a valid npm registry result', () => {
expect(getLatestNpmSpec(claudePackage, registry(claudePackage, null))).toBe(`${claudePackage}@0.7.0`);
expect(getLatestNpmSpec(claudePackage, {
kind: 'npm-network',
spec: claudePackage,
error: 'offline',
})).toBeNull();
});
test('chooses an update for a bare or outdated user-wide entry', () => {
const bare = getCatalogPluginState(
[entry(claudePackage)],
claudePackage,
{ [claudePackage]: registry(claudePackage, null) },
);
const outdated = getCatalogPluginState(
[entry(`${claudePackage}@0.6.0`)],
claudePackage,
{ [`${claudePackage}@0.6.0`]: registry(`${claudePackage}@0.6.0`, '0.6.0') },
);
expect(getCatalogPluginPrimaryAction(bare, claudePackage)).toBe('update');
expect(getCatalogPluginPrimaryAction(outdated, claudePackage)).toBe('update');
});
test('keeps setup as the primary action once the exact latest spec is installed', () => {
const installed = entry(`${claudePackage}@0.7.0`);
const state = getCatalogPluginState(
[installed],
claudePackage,
{ [installed.spec]: registry(installed.spec, '0.7.0') },
);
expect(getCatalogPluginPrimaryAction(state, claudePackage)).toBe('setup');
});
test('sends ambiguous entries to manual plugin management', () => {
const state = getCatalogPluginState(
[entry(claudePackage), entry(`${claudePackage}@0.6.0`)],
claudePackage,
{},
);
expect(getCatalogPluginPrimaryAction(state, claudePackage)).toBe('manage');
});
});
@@ -0,0 +1,156 @@
import type { IconName } from '@/components/icon/icons';
import type { I18nKey } from '@/lib/i18n';
import type { PluginEntry, RegistryResult } from '@/stores/usePluginsStore';
export interface ThirdPartyPluginDefinition {
id: string;
packageName: string;
providerId: string;
icon: IconName;
/** Brand mark tint (e.g. Claude orange); neutral marks use text-foreground. */
brandClassName: string;
nameKey: I18nKey;
descriptionKey: I18nKey;
homepage: string;
}
export const THIRD_PARTY_PLUGINS: readonly ThirdPartyPluginDefinition[] = [
{
id: 'opencode-claude',
packageName: '@openchamber/opencode-claude',
providerId: 'claude-code',
icon: 'claude-code',
brandClassName: 'text-[#D97757]',
nameKey: 'settings.integrations.thirdParty.opencodeClaude.name',
descriptionKey: 'settings.integrations.thirdParty.opencodeClaude.description',
homepage: 'https://github.com/openchamber/opencode-claude',
},
{
id: 'opencode-cursor-oauth',
packageName: '@openchamber/opencode-cursor',
providerId: 'cursor',
icon: 'cursor',
brandClassName: 'text-foreground',
nameKey: 'settings.integrations.thirdParty.opencodeCursorOauth.name',
descriptionKey: 'settings.integrations.thirdParty.opencodeCursorOauth.description',
homepage: 'https://github.com/openchamber/opencode-cursor',
},
] as const;
export interface CatalogPluginState {
userEntry: PluginEntry | null;
userEntryIsAmbiguous: boolean;
projectEntries: PluginEntry[];
registry: RegistryResult | null;
}
export type CatalogPluginPrimaryAction = 'install' | 'update' | 'setup' | 'manage';
type CatalogPluginPresentationStatus =
| 'not-installed'
| 'installed'
| 'installed-version'
| 'update-available'
| 'unpinned'
| 'ambiguous'
| 'restart-required'
| 'registry-unavailable'
| 'provider-unavailable';
interface CatalogPluginPresentationOptions {
registryUnavailable?: boolean;
restartRequired?: boolean;
providerUnavailable?: boolean;
}
interface CatalogPluginPresentation {
status: CatalogPluginPresentationStatus;
latestVersion: string | null;
}
export const specMatchesPackage = (spec: string, packageName: string): boolean =>
spec === packageName || spec.startsWith(`${packageName}@`);
export function getCatalogPluginState(
entries: PluginEntry[],
packageName: string,
registryInfo: Record<string, RegistryResult>,
): CatalogPluginState {
const matchingEntries = entries.filter((entry) => specMatchesPackage(entry.spec, packageName));
const userEntries = matchingEntries.filter((entry) => entry.scope === 'user');
const projectEntries = matchingEntries.filter((entry) => entry.scope === 'project');
const userEntry = userEntries.length === 1 ? userEntries[0] : null;
const registry = registryInfo[userEntry?.spec ?? packageName] ?? registryInfo[packageName] ?? null;
return {
userEntry,
userEntryIsAmbiguous: userEntries.length > 1,
projectEntries,
registry,
};
}
export function getLatestNpmSpec(
packageName: string,
registry: RegistryResult | null | undefined,
): string | null {
if (registry?.kind !== 'npm-ok' || registry.name !== packageName || !registry.latestVersion) {
return null;
}
return `${packageName}@${registry.latestVersion}`;
}
export function getCatalogPluginPrimaryAction(
state: CatalogPluginState,
packageName: string,
): CatalogPluginPrimaryAction {
if (state.userEntryIsAmbiguous) {
return 'manage';
}
if (!state.userEntry) {
return 'install';
}
const latestSpec = getLatestNpmSpec(packageName, state.registry);
return latestSpec && latestSpec !== state.userEntry.spec ? 'update' : 'setup';
}
/**
* Converts catalog and temporary mutation state into the one compact-card
* status. Transient states intentionally outrank installed/version metadata.
*/
export function getCatalogPluginPresentation(
state: CatalogPluginState,
options: CatalogPluginPresentationOptions = {},
): CatalogPluginPresentation {
const latestVersion = state.registry?.kind === 'npm-ok'
? state.registry.latestVersion
: null;
if (state.userEntryIsAmbiguous) {
return { status: 'ambiguous', latestVersion };
}
if (options.restartRequired) {
return { status: 'restart-required', latestVersion };
}
if (options.providerUnavailable) {
return { status: 'provider-unavailable', latestVersion };
}
if (options.registryUnavailable) {
return { status: 'registry-unavailable', latestVersion };
}
if (!state.userEntry) {
return { status: 'not-installed', latestVersion };
}
if (state.registry?.kind === 'npm-ok' && state.registry.currentVersion === state.registry.latestVersion) {
return { status: 'installed-version', latestVersion };
}
if (state.registry?.kind === 'npm-ok' && state.registry.currentVersion === null) {
return { status: 'unpinned', latestVersion };
}
if (state.registry?.kind === 'npm-ok' && latestVersion) {
return { status: 'update-available', latestVersion };
}
return { status: 'installed', latestVersion };
}
@@ -2,10 +2,29 @@ import React from 'react';
import { Button } from '@/components/ui/button';
import { useMcpStore } from '@/stores/useMcpStore';
import { parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth';
import { MCP_OAUTH_ORIGIN_DESKTOP } from '@/components/sections/mcp/startMcpAuthorization';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { SETTINGS_PAGE_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
import { cn } from '@/lib/utils';
/**
* Handing control back after the browser finished the authorization.
*
* This page always runs in a browser, but the flow may have been started from
* the desktop shell a different surface entirely. Sending that user to `/`
* would raise a second copy of the interface in a tab while the real app sits
* behind it, so the desktop case is returned through its own protocol, which
* focuses the running window.
*/
const returnToApp = (startedFromDesktop: boolean): void => {
if (typeof window === 'undefined') return;
if (startedFromDesktop) {
window.location.href = 'openchamber://focus/mcp-auth';
return;
}
window.location.replace('/');
};
const parseQueryParam = (params: URLSearchParams, key: string): string | null => {
const value = params.get(key);
if (typeof value !== 'string') {
@@ -27,6 +46,7 @@ const normalizeMcpAuthErrorMessage = (error: unknown, fallback: string): string
export const McpOAuthCallbackPage: React.FC = () => {
const completeAuth = useMcpStore((state) => state.completeAuth);
const [status, setStatus] = React.useState<'working' | 'success' | 'error'>('working');
const [returnToDesktop, setReturnToDesktop] = React.useState(false);
const [message, setMessage] = React.useState('Completing MCP authorization...');
React.useEffect(() => {
@@ -59,11 +79,21 @@ export const McpOAuthCallbackPage: React.FC = () => {
}
let pendingContext = callbackContext;
if (!pendingContext && callbackStateKey) {
let startedFromDesktop = false;
// Always consulted, even when the state already carries the server:
// the origin lives only here, and it decides where the user is sent
// back to.
if (callbackStateKey) {
const response = await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`);
if (response.ok) {
const payload = await response.json().catch(() => null) as { name?: string; directory?: string | null } | null;
if (payload?.name?.trim()) {
const payload = await response.json().catch(() => null) as {
name?: string;
directory?: string | null;
origin?: string | null;
} | null;
startedFromDesktop = payload?.origin === MCP_OAUTH_ORIGIN_DESKTOP;
setReturnToDesktop(startedFromDesktop);
if (!pendingContext && payload?.name?.trim()) {
pendingContext = {
name: payload.name.trim(),
directory: typeof payload.directory === 'string' && payload.directory.trim() ? payload.directory.trim() : null,
@@ -81,6 +111,12 @@ export const McpOAuthCallbackPage: React.FC = () => {
await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`, { method: 'DELETE' }).catch(() => undefined);
}
setStatus('success');
// Attempted straight away: the user's attention is in a browser tab,
// and the app they were working in is behind it. The button below
// stays as the fallback for a browser that blocks the protocol jump.
if (startedFromDesktop) {
returnToApp(true);
}
setMessage('Authorization completed. You can close this tab and return to OpenChamber.');
} catch (authError) {
if (callbackStateKey) {
@@ -117,12 +153,7 @@ export const McpOAuthCallbackPage: React.FC = () => {
<div className="mt-8 flex justify-center">
<Button
type="button"
onClick={() => {
if (typeof window === 'undefined') {
return;
}
window.location.replace('/');
}}
onClick={() => returnToApp(returnToDesktop)}
>
Return to OpenChamber
</Button>
@@ -7,6 +7,7 @@ import { copyTextToClipboard } from '@/lib/clipboard';
import { openExternalUrl } from '@/lib/url';
import { isVSCodeRuntime } from '@/lib/desktop';
import {
selectMcpServersForDirectory,
useMcpConfigStore,
envRecordToArray,
type McpDraft,
@@ -18,23 +19,23 @@ import {
applyImportedMcpToDraft,
} from './mcpImport';
import { useMcpStore } from '@/stores/useMcpStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { usePendingOpenCodeRestartStore } from '@/stores/usePendingOpenCodeRestartStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { cn } from '@/lib/utils';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import {
SettingsSection,
SettingsFieldRow,
SettingsCheckboxRow,
SettingsStackedField,
SettingsChipGroup,
SettingsGroupTitle,
SettingsStackedField,
SETTINGS_SELECT_SIZE,
SETTINGS_FIELD_LABEL_CLASS,
} from '@/components/sections/shared/SettingsSection';
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
import { MCP_OAUTH_CALLBACK_PATH, parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth';
import { parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth';
import { buildMcpAuthorizationRedirectUri, startMcpAuthorization } from '@/components/sections/mcp/startMcpAuthorization';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
Dialog,
@@ -51,6 +52,7 @@ import {
SelectTrigger,
} from '@/components/ui/select';
import { Icon } from "@/components/icon/Icon";
import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip';
import { useI18n } from '@/lib/i18n';
// ─────────────────────────────────────────────────────────────
@@ -59,11 +61,9 @@ import { useI18n } from '@/lib/i18n';
interface CommandTextareaProps {
value: string[];
onChange: (v: string[]) => void;
pasteCommandTitle: string;
pasteCommandLabel: string;
pasteSuccess: (count: number) => string;
clipboardReadFailed: string;
preview: (count: number) => string;
/** Called when the text is plainly a link rather than a command. */
onDetectUrl?: (url: string) => void;
}
/**
@@ -124,11 +124,8 @@ function extractAuthorizationResponse(raw: string): {
const CommandTextarea: React.FC<CommandTextareaProps> = ({
value,
onChange,
pasteCommandTitle,
pasteCommandLabel,
pasteSuccess,
clipboardReadFailed,
preview,
onDetectUrl,
}) => {
// Internal: one arg per line
const [text, setText] = React.useState(() => value.join('\n'));
@@ -144,42 +141,38 @@ const CommandTextarea: React.FC<CommandTextareaProps> = ({
const commit = (raw: string) => {
const lines = raw.split('\n').filter((l) => l.trim().length > 0);
// A single line that is nothing but a URL is a hosted server, not a
// command to run — the page switches kind rather than making the user say.
if (onDetectUrl && lines.length === 1 && /^https?:\/\/\S+$/i.test(lines[0].trim())) {
onDetectUrl(lines[0].trim());
return;
}
onChange(lines);
};
const handlePasteFromClipboard = async () => {
try {
const raw = await navigator.clipboard.readText();
const trimmed = raw.trim();
// If it looks like a multi-line list, keep as-is; otherwise parse as shell command
const lines = trimmed.includes('\n')
? trimmed.split('\n').filter((l) => l.trim())
: parseShellCommand(trimmed);
setText(lines.join('\n'));
onChange(lines);
toast.success(pasteSuccess(lines.length));
} catch {
toast.error(clipboardReadFailed);
}
/**
* Pasting a whole command line splits it into arguments here, in the field
* the user pasted into. The old approach a button that read the clipboard
* itself fails outright wherever the runtime denies clipboard reads.
*/
const handlePaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
const raw = event.clipboardData.getData('text');
const trimmed = raw.trim();
// Only take over a paste that replaces the whole field with one command
// line; anything else is ordinary editing and belongs to the browser.
if (!trimmed || trimmed.includes('\n') || !/\s/.test(trimmed)) return;
const target = event.currentTarget;
if (target.selectionStart !== 0 || target.selectionEnd !== target.value.length) return;
event.preventDefault();
const lines = parseShellCommand(trimmed);
setText(lines.join('\n'));
onChange(lines);
};
return (
<div className="space-y-2" data-bwignore="true" data-1p-ignore="true" data-lpignore="true">
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="xs"
className="!font-normal gap-1 text-muted-foreground"
onClick={handlePasteFromClipboard}
type="button"
title={pasteCommandTitle}
>
<Icon name="clipboard" className="h-3 w-3" />
{pasteCommandLabel}
</Button>
</div>
<Textarea
onPaste={handlePaste}
value={text}
onChange={(e) => {
setText(e.target.value);
@@ -198,7 +191,7 @@ const CommandTextarea: React.FC<CommandTextareaProps> = ({
'npx\n-y\n@modelcontextprotocol/server-postgres\npostgresql://user:pass@host/db'
}
rows={Math.max(4, value.length + 1)}
className="font-mono typography-meta resize-y min-h-[80px]"
className="font-mono typography-meta min-h-[80px]"
spellCheck={false}
/>
@@ -448,6 +441,7 @@ const StatusBadge: React.FC<{
failed: { text: 'text-[var(--status-error)]', bg: 'bg-[var(--status-error)]/10' },
needs_auth: { text: 'text-[var(--status-warning)]', bg: 'bg-[var(--status-warning)]/10' },
needs_client_registration: { text: 'text-[var(--status-warning)]', bg: 'bg-[var(--status-warning)]/10' },
awaiting_restart: { text: 'text-[var(--status-warning)]', bg: 'bg-[var(--status-warning)]/10' },
};
const colors = colorClassMap[status] ?? { text: 'text-muted-foreground', bg: '' };
@@ -508,42 +502,6 @@ const shouldShowFullStatusCard = (status: string | undefined, authUrl: string |
return false;
};
const buildMcpOAuthRedirectUri = (name?: string | null, directory?: string | null): string | null => {
if (typeof window === 'undefined') {
return null;
}
const url = new URL(MCP_OAUTH_CALLBACK_PATH, getRuntimeApiBaseUrl() || window.location.origin);
if (typeof name === 'string' && name.trim()) {
url.searchParams.set('server', name.trim());
}
if (typeof directory === 'string' && directory.trim()) {
url.searchParams.set('directory', directory.trim());
}
return url.toString();
};
const queuePendingMcpAuthContext = async (input: {
state: string;
name: string;
directory?: string | null;
}): Promise<void> => {
const response = await runtimeFetch('/api/mcp/auth/pending', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
state: input.state,
name: input.name,
directory: typeof input.directory === 'string' && input.directory.trim() ? input.directory.trim() : null,
}),
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.error || 'Failed to prepare MCP authorization callback');
}
};
const getPendingMcpAuthContext = async (stateKey: string): Promise<{ name: string; directory: string | null } | null> => {
const response = await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(stateKey)}`);
if (!response.ok) {
@@ -599,7 +557,6 @@ export const McpPage: React.FC = () => {
);
const {
selectedMcpName,
mcpServers,
mcpDraft,
setMcpDraft,
setSelectedMcp,
@@ -609,7 +566,6 @@ export const McpPage: React.FC = () => {
deleteMcp,
} = useMcpConfigStore(useShallow((s) => ({
selectedMcpName: s.selectedMcpName,
mcpServers: s.mcpServers,
mcpDraft: s.mcpDraft,
setMcpDraft: s.setMcpDraft,
setSelectedMcp: s.setSelectedMcp,
@@ -619,19 +575,22 @@ export const McpPage: React.FC = () => {
deleteMcp: s.deleteMcp,
})));
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const currentDirectory = useSettingsDirectory();
const isVSCodeAuthRuntime = React.useMemo(() => isVSCodeRuntime(), []);
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(currentDirectory ?? null));
const mcpDiagnostics = useMcpStore((state) => state.getDiagnosticForDirectory(currentDirectory ?? null));
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(currentDirectory));
const mcpDiagnostics = useMcpStore((state) => state.getDiagnosticForDirectory(currentDirectory));
const refreshStatus = useMcpStore((state) => state.refresh);
const connectMcp = useMcpStore((state) => state.connect);
const disconnectMcp = useMcpStore((state) => state.disconnect);
const startAuthMcp = useMcpStore((state) => state.startAuth);
const completeAuthMcp = useMcpStore((state) => state.completeAuth);
const clearAuthMcp = useMcpStore((state) => state.clearAuth);
const testConnectionMcp = useMcpStore((state) => state.testConnection);
const pendingRestartChanges = usePendingOpenCodeRestartStore((state) => state.changes);
const selectedServer = selectedMcpName ? getMcpByName(selectedMcpName) : null;
const mcpServers = useMcpConfigStore((state) => selectMcpServersForDirectory(state, currentDirectory));
const selectedServer = selectedMcpName ? getMcpByName(selectedMcpName, currentDirectory) : null;
const isNewServer = Boolean(mcpDraft && mcpDraft.name === selectedMcpName && !selectedServer);
// ── form state ──
@@ -887,6 +846,39 @@ export const McpPage: React.FC = () => {
);
}, [mcpType, command, url, envEntries, headerEntries, oauthEnabled, oauthClientId, oauthClientSecret, oauthScope, oauthRedirectUri, timeout, enabled]);
// What the user has is either a command they were given or a link. Which of
// the two decides the transport, so the page reads it off the text instead of
// asking — and lets them correct it when the text alone cannot say.
const connectionKindTabs = React.useMemo<SortableTabsStripItem[]>(() => [
{
id: 'local',
label: t('settings.mcp.page.connection.kindCommand'),
icon: <Icon name="terminal" className="h-3.5 w-3.5" />,
},
{
id: 'remote',
label: t('settings.mcp.page.connection.kindLink'),
icon: <Icon name="global" className="h-3.5 w-3.5" />,
},
], [t]);
const handleDetectedUrl = React.useCallback((candidate: string) => {
setMcpType('remote');
setUrl(candidate);
setCommand([]);
}, []);
const handleUrlChange = React.useCallback((next: string) => {
setUrl(next);
// A command pasted into the link field is still a command.
const trimmed = next.trim();
if (trimmed && !/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) && /\s/.test(trimmed)) {
setMcpType('local');
setCommand(parseShellCommand(trimmed));
setUrl('');
}
}, []);
const handleSave = async () => {
const name = isNewServer ? draftName.trim() : selectedMcpName ?? '';
if (!name) { toast.error(t('settings.mcp.page.toast.nameRequired')); return; }
@@ -918,7 +910,7 @@ export const McpPage: React.FC = () => {
};
setIsSaving(true);
try {
const result = isNewServer ? await createMcp(draft) : await updateMcp(name, draft);
const result = isNewServer ? await createMcp(draft, currentDirectory) : await updateMcp(name, draft, currentDirectory);
if (result.ok) {
await clearPendingMcpAuthContext(authStateKey);
resetTransientAuthState();
@@ -930,6 +922,8 @@ export const McpPage: React.FC = () => {
: t('settings.mcp.page.toast.savedReloadFailed')), {
description: result.warning || t('settings.mcp.page.toast.retryRefreshHint'),
});
} else if (result.restartDeferred) {
toast.success(t('settings.view.pendingRestart.saved'));
} else {
toast.success(result.message || (isNewServer
? t('settings.mcp.page.toast.serverCreatedReloading')
@@ -948,7 +942,7 @@ export const McpPage: React.FC = () => {
const handleDelete = async () => {
if (!selectedMcpName) return;
setIsDeleting(true);
const result = await deleteMcp(selectedMcpName);
const result = await deleteMcp(selectedMcpName, currentDirectory);
if (result.ok) {
await clearPendingMcpAuthContext(authStateKey);
resetTransientAuthState();
@@ -975,7 +969,7 @@ export const McpPage: React.FC = () => {
} else {
await connectMcp(selectedMcpName, currentDirectory);
await refreshStatus({ directory: currentDirectory, silent: true });
const nextStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory ?? null)[selectedMcpName];
const nextStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory)[selectedMcpName];
if (nextStatus?.status === 'connected') {
toast.success(t('settings.mcp.page.toast.connected'));
} else if (nextStatus?.status === 'needs_auth') {
@@ -1037,51 +1031,48 @@ export const McpPage: React.FC = () => {
const actionKey = runtimeActionKey;
let queuedStateKey: string | null = null;
try {
const currentStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory ?? null)[selectedMcpName]?.status;
const currentStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory)[selectedMcpName]?.status;
authPollStartsFromNeedsAuthRef.current = currentStatus === 'needs_auth' || currentStatus === 'needs_client_registration';
const redirectUri = buildMcpOAuthRedirectUri(selectedMcpName, currentDirectory);
if (!redirectUri) {
throw new Error(t('settings.mcp.page.toast.oauthRedirectUrlBuildFailed'));
// One implementation for every surface that can authorise; the page
// used to own this flow while the dropdown and the work-status panel
// called plain `connect`, which cannot start OAuth at all.
const { authorizationUrl: nextAuthUrl, opened, nativeFlow, completion } = await startMcpAuthorization({
name: selectedMcpName,
directory: currentDirectory,
});
if (nativeFlow) {
// OpenCode opened the browser and completes the flow itself; there is
// no URL or state to track. The completion promise is the authoritative
// end signal — status polling alone cannot tell a finished
// reauthorization from the still-connected state it started in.
if (runtimeActionKeyRef.current !== actionKey) return;
setAuthUrl(null);
setAuthStateKey(null);
setIsAuthPolling(true);
authPollAttemptsRef.current = 0;
toast.message(t('settings.mcp.page.toast.completeAuthorizationInBrowser'));
completion
?.then(() => {
if (runtimeActionKeyRef.current !== actionKey) return;
setIsAuthPolling(false);
authPollAttemptsRef.current = 0;
authPollStartsFromNeedsAuthRef.current = false;
toast.success(t('settings.mcp.page.toast.authorizationCompleted'));
})
.catch((completionError) => {
if (runtimeActionKeyRef.current !== actionKey) return;
setIsAuthPolling(false);
authPollAttemptsRef.current = 0;
authPollStartsFromNeedsAuthRef.current = false;
toast.error(normalizeMcpAuthErrorMessage(completionError, t('settings.mcp.page.toast.authorizationFailed'), tUnsafe));
});
return;
}
if (!oauthRedirectUri.trim() && !isVSCodeAuthRuntime) {
const saved = await updateMcp(selectedMcpName, {
oauthEnabled,
oauthClientId,
oauthClientSecret,
oauthScope,
oauthRedirectUri: redirectUri,
});
if (!saved.ok) {
throw new Error(t('settings.mcp.page.toast.oauthBrowserCallbackSaveFailed'));
}
if (saved.reloadFailed) {
throw new Error(saved.warning || saved.message || t('settings.mcp.page.toast.openCodeReloadFailedAfterCallbackSave'));
}
if (runtimeActionKeyRef.current !== actionKey) {
return;
}
setOauthRedirectUri(redirectUri);
initialRef.current = initialRef.current
? { ...initialRef.current, oauthRedirectUri: redirectUri }
: initialRef.current;
}
const nextAuthUrl = await startAuthMcp(selectedMcpName, currentDirectory);
const stateKey = parseMcpOAuthCallbackStateKey(new URL(nextAuthUrl).searchParams);
if (stateKey) {
queuedStateKey = stateKey;
await queuePendingMcpAuthContext({
state: stateKey,
name: selectedMcpName,
directory: currentDirectory,
});
}
queuedStateKey = stateKey;
if (runtimeActionKeyRef.current !== actionKey) {
return;
@@ -1092,7 +1083,6 @@ export const McpPage: React.FC = () => {
setIsAuthPolling(true);
authPollAttemptsRef.current = 0;
const opened = await openExternalUrl(nextAuthUrl);
if (runtimeActionKeyRef.current !== actionKey) {
return;
}
@@ -1116,7 +1106,7 @@ export const McpPage: React.FC = () => {
setIsAuthorizing(false);
}
}
}, [currentDirectory, isVSCodeAuthRuntime, mcpType, oauthClientId, oauthClientSecret, oauthEnabled, oauthRedirectUri, oauthScope, requireSavedConfig, runtimeActionKey, selectedMcpName, startAuthMcp, t, tUnsafe, updateMcp]);
}, [currentDirectory, isVSCodeAuthRuntime, mcpType, requireSavedConfig, runtimeActionKey, selectedMcpName, t, tUnsafe]);
const handleClearAuthorization = React.useCallback(async () => {
if (!selectedMcpName || !requireSavedConfig()) return;
@@ -1249,7 +1239,7 @@ export const McpPage: React.FC = () => {
void (async () => {
authPollAttemptsRef.current += 1;
await refreshStatus({ directory: currentDirectory, silent: true });
const nextStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory ?? null)[selectedMcpName];
const nextStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory)[selectedMcpName];
if (!nextStatus) {
return;
@@ -1309,9 +1299,33 @@ export const McpPage: React.FC = () => {
const runtimeStatus = mcpStatus[selectedMcpName];
const runtimeDiagnostic = selectedMcpName ? mcpDiagnostics[selectedMcpName] : undefined;
const effectiveRuntimeStatus = runtimeStatus ?? runtimeDiagnostic;
// Saved into the config but queued behind Apply & Restart: OpenCode does not
// know this server yet, so every runtime action (connect, authorize, clear
// auth) can only fail with "server not found". The page says that instead of
// offering the buttons.
const isAwaitingRestart = !isNewServer && !effectiveRuntimeStatus
&& pendingRestartChanges.some((change) => change.scope === 'mcp' && change.id.startsWith(`mcp:${selectedMcpName}:`));
const isConnected = runtimeStatus?.status === 'connected';
const needsAuthorization = runtimeStatus?.status === 'needs_auth' || runtimeStatus?.status === 'needs_client_registration';
const suggestedRedirectUri = isVSCodeAuthRuntime ? null : buildMcpOAuthRedirectUri(selectedMcpName, currentDirectory);
// Must be the very URI `startMcpAuthorization` writes into the config, not a
// second construction of it. The page used to suggest a directory-bearing
// address while the flow sent a directory-less one, so a provider enforcing
// exact redirect matching rejected a registration copied from right here.
const suggestedRedirectUri = isVSCodeAuthRuntime || !selectedMcpName
? null
: buildMcpAuthorizationRedirectUri(selectedMcpName);
const handleCopyRedirectUri = async () => {
if (!suggestedRedirectUri) return;
try {
await navigator.clipboard.writeText(suggestedRedirectUri);
toast.success(t('settings.mcp.page.toast.copiedCallbackUrl'));
} catch {
toast.error(t('settings.mcp.page.toast.clipboardWriteFailed'));
}
};
const runtimeDescription = getStatusDescription(
effectiveRuntimeStatus?.status,
tUnsafe,
@@ -1327,6 +1341,8 @@ export const McpPage: React.FC = () => {
return t('settings.mcp.page.status.label.needsAuth');
case 'needs_client_registration':
return t('settings.mcp.page.status.label.needsRegistration');
case 'awaiting_restart':
return t('settings.mcp.page.status.label.awaitingRestart');
default:
return status;
}
@@ -1337,12 +1353,17 @@ export const McpPage: React.FC = () => {
<SettingsPageLayout
title={isNewServer ? t('settings.mcp.page.header.newServer') : selectedMcpName}
titleAccessory={!isNewServer ? (
<StatusBadge status={effectiveRuntimeStatus?.status} enabled={enabled} getStatusLabel={getStatusLabel} variant="pill" />
<StatusBadge
status={isAwaitingRestart ? 'awaiting_restart' : effectiveRuntimeStatus?.status}
enabled={enabled}
getStatusLabel={getStatusLabel}
variant="pill"
/>
) : undefined}
description={isNewServer
? t('settings.mcp.page.header.configureNewServer')
: t('settings.mcp.page.header.transport', { type: mcpType === 'local' ? t('settings.mcp.page.transport.local') : t('settings.mcp.page.transport.remote') })}
headerEnd={!isNewServer ? (
headerEnd={!isNewServer && !isAwaitingRestart ? (
<div className="flex flex-wrap items-center gap-2">
<Button
variant={isConnected ? 'outline' : 'default'}
@@ -1362,11 +1383,15 @@ export const McpPage: React.FC = () => {
onClick={() => void handleStartAuthorization()}
disabled={isAuthorizing || !enabled}
>
{/* "Reauthorize" only once a working authorization exists (the
server is connected); every other state needs_auth,
failed, still unknown reads "Authorize" so the label does
not imply stored credentials that may not be there. */}
{isAuthorizing
? t('settings.mcp.page.actions.starting')
: needsAuthorization
? t('settings.mcp.page.actions.authorize')
: t('settings.mcp.page.actions.reauthorize')}
: isConnected
? t('settings.mcp.page.actions.reauthorize')
: t('settings.mcp.page.actions.authorize')}
</Button>
<Button
variant="ghost"
@@ -1397,8 +1422,26 @@ export const McpPage: React.FC = () => {
{/* Saved but queued behind Apply & Restart: dynamic status the user
must see, or the missing action buttons read as a broken page. */}
{isAwaitingRestart && (
<SettingsSection divider={false}>
<div className="rounded-lg border p-3 border-[var(--status-warning-border)] bg-[var(--status-warning-background)]">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.mcp.page.status.runtimeStatus')}</span>
<StatusBadge status="awaiting_restart" enabled={enabled} getStatusLabel={getStatusLabel} />
</div>
<p className="typography-meta text-muted-foreground">
{t('settings.mcp.page.status.description.awaitingRestart')}
</p>
</div>
</div>
</SettingsSection>
)}
{/* Runtime Status - Simplified for connected, expanded for errors */}
{!isNewServer && shouldShowFullStatusCard(effectiveRuntimeStatus?.status, authUrl, needsAuthorization, isAuthPolling) && (
{!isNewServer && !isAwaitingRestart && shouldShowFullStatusCard(effectiveRuntimeStatus?.status, authUrl, needsAuthorization, isAuthPolling) && (
<SettingsSection divider={false}>
<div className={cn('rounded-lg border p-3', statusCardClass(effectiveRuntimeStatus?.status))}>
<div className="space-y-4">
@@ -1429,6 +1472,69 @@ export const McpPage: React.FC = () => {
)}
</div>
{/* Client credentials appear only when the server has said it
needs them. Kept as a permanent four-field form, they made
the rarest case the most prominent thing on the page and
told nobody what to put there. */}
{effectiveRuntimeStatus?.status === 'needs_client_registration' && (
<div className="space-y-3 rounded-md border border-[var(--interactive-border)] bg-[var(--surface-background)] px-3 py-3">
<div>
<SettingsGroupTitle as="div">{t('settings.mcp.page.registration.title')}</SettingsGroupTitle>
<p className="mt-1 typography-micro text-muted-foreground">
{t('settings.mcp.page.registration.description')}
</p>
</div>
{suggestedRedirectUri && (
<div>
<div className="typography-micro text-muted-foreground">
{t('settings.mcp.page.registration.callbackLabel')}
</div>
<div className="mt-1 flex items-start gap-2">
<span className="min-w-0 flex-1 break-all font-mono typography-micro text-foreground/80">
{suggestedRedirectUri}
</span>
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => void handleCopyRedirectUri()}
>
<Icon name="clipboard" className="h-3.5 w-3.5" />
{t('settings.mcp.page.actions.copyLink')}
</Button>
</div>
</div>
)}
<div className="grid gap-3 @xl:grid-cols-2">
<SettingsStackedField label={t('settings.mcp.page.registration.clientId')}>
<Input
value={oauthClientId}
onChange={(e) => { setOauthClientId(e.target.value); setOauthEnabled(true); }}
className="font-mono typography-meta"
data-bwignore="true"
data-1p-ignore="true"
/>
</SettingsStackedField>
<SettingsStackedField label={t('settings.mcp.page.registration.clientSecret')}>
<Input
type="password"
value={oauthClientSecret}
onChange={(e) => { setOauthClientSecret(e.target.value); setOauthEnabled(true); }}
className="font-mono typography-meta"
data-bwignore="true"
data-1p-ignore="true"
/>
</SettingsStackedField>
</div>
<p className="typography-micro text-muted-foreground">
{t('settings.mcp.page.registration.afterSaving')}
</p>
</div>
)}
{authUrl && (
<div className="rounded-md border border-[var(--interactive-border)] bg-[var(--surface-background)] px-3 py-2">
<div className="space-y-2">
@@ -1448,7 +1554,11 @@ export const McpPage: React.FC = () => {
</div>
)}
{mcpType === 'remote' && (needsAuthorization || isAuthPolling || authUrl) && (
{/* VS Code only. Everywhere else the callback returns into the
app on its own, so the paste box was a second, confusing way
to do what already happened. VS Code cannot receive that
redirect, so there it remains the only way to finish. */}
{isVSCodeAuthRuntime && mcpType === 'remote' && (needsAuthorization || isAuthPolling || authUrl) && (
<div className="rounded-md border border-[var(--interactive-border)] bg-[var(--surface-background)] px-3 py-3">
<div className="space-y-2">
<div>
@@ -1462,7 +1572,7 @@ export const McpPage: React.FC = () => {
onChange={(event) => setAuthCallbackInput(event.target.value)}
placeholder={t('settings.mcp.page.auth.callbackInputPlaceholder')}
rows={3}
className="font-mono typography-meta resize-y"
className="font-mono typography-meta"
data-bwignore="true"
data-1p-ignore="true"
spellCheck={false}
@@ -1497,32 +1607,60 @@ export const McpPage: React.FC = () => {
divider={false}
settingsItem="mcp.server"
contentClassName="space-y-0"
titleAccessory={isNewServer ? (
<Button
variant="ghost"
size="xs"
className="!font-normal gap-1.5 text-muted-foreground"
onClick={handleOpenImportDialog}
type="button"
title={t('settings.mcp.page.server.importJsonTitle')}
>
<Icon name="file-code" className="h-3.5 w-3.5" />
{t('settings.mcp.page.server.importJson')}
</Button>
) : null}
>
{isNewServer && (
<SettingsFieldRow label={t('settings.mcp.page.server.name')}>
<SettingsFieldRow
label={t('settings.mcp.page.server.name')}
// The scope select carries words now, not a lone icon, so the
// control cluster has to be allowed to bound itself and wrap.
// Left at its default (fit-width, no shrink) the pair ran past
// the edge of the settings pane in a narrow dialog.
controlClassName="flex-wrap @xl:w-auto @xl:flex-1"
>
<Input
value={draftName}
onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '-'))}
placeholder={t('settings.mcp.page.server.namePlaceholder')}
className="h-7 w-48 font-mono px-2"
className="h-7 w-48 min-w-0 max-w-full shrink font-mono px-2"
autoFocus
/>
<Select value={draftScope} onValueChange={(value) => setDraftScope(value as McpScope)}>
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="!h-7 !w-7 !min-w-0 !px-0 !py-0 justify-center [&>svg:last-child]:hidden" title={draftScope === 'user' ? t('settings.common.scope.global') : t('settings.common.scope.project')}>
{draftScope === 'user' ? <Icon name="user-3" className="h-3.5 w-3.5" /> : <Icon name="folder" className="h-3.5 w-3.5" />}
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="!h-7 min-w-0 max-w-full gap-1.5 px-2">
<Icon
name={draftScope === 'user' ? 'user-3' : 'folder'}
className="h-3.5 w-3.5 shrink-0"
/>
<span className="truncate">
{draftScope === 'user'
? t('settings.mcp.page.scope.everywhere')
: t('settings.mcp.page.scope.thisProject')}
</span>
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="user">
<div className="flex items-center gap-2">
<Icon name="user-3" className="h-3.5 w-3.5" />
<span>{t('settings.common.scope.global')}</span>
<span>{t('settings.mcp.page.scope.everywhere')}</span>
</div>
</SelectItem>
<SelectItem value="project">
<div className="flex items-center gap-2">
<Icon name="folder" className="h-3.5 w-3.5" />
<span>{t('settings.common.scope.project')}</span>
<span>{t('settings.mcp.page.scope.thisProject')}</span>
</div>
</SelectItem>
</SelectContent>
@@ -1530,23 +1668,6 @@ export const McpPage: React.FC = () => {
</SettingsFieldRow>
)}
{/* Import JSON - prominent placement for new servers */}
{isNewServer && (
<div className="py-1.5">
<Button
variant="outline"
size="xs"
className="!font-normal gap-1.5"
onClick={handleOpenImportDialog}
type="button"
title={t('settings.mcp.page.server.importJsonTitle')}
>
<Icon name="file-code" className="h-3.5 w-3.5" />
{t('settings.mcp.page.server.importJson')}
</Button>
</div>
)}
<SettingsCheckboxRow
checked={enabled}
onChange={setEnabled}
@@ -1554,42 +1675,50 @@ export const McpPage: React.FC = () => {
ariaLabel={t('settings.mcp.page.server.enableAria')}
/>
<SettingsStackedField label={t('settings.mcp.page.server.transportMode')}>
<SettingsChipGroup
aria-label={t('settings.mcp.page.server.transportMode')}
value={mcpType}
onChange={setMcpType}
options={[
{ value: 'local', label: t('settings.mcp.page.transport.local') },
{ value: 'remote', label: t('settings.mcp.page.transport.remote') },
]}
/>
</SettingsStackedField>
</SettingsSection>
<SettingsSection
title={mcpType === 'local' ? t('settings.mcp.page.connection.command') : t('settings.mcp.page.connection.serverUrl')}
title={t('settings.mcp.page.connection.title')}
description={t('settings.mcp.page.connection.description')}
settingsItem="mcp.command"
// The section's content wrapper carries no spacing of its own, so the
// kind tabs, the field and its hint would otherwise sit flush.
contentClassName="space-y-2"
>
{/* Pasting a link or a command still flips this for you, but the
choice is a control you can see and press. As one sentence with
an inline link it was, in practice, undiscoverable. */}
<SortableTabsStrip
items={connectionKindTabs}
activeId={mcpType}
onSelect={(id) => setMcpType(id as 'local' | 'remote')}
layoutMode="fit"
variant="active-pill"
activePillLowercase={false}
className="h-10"
/>
{mcpType === 'local' ? (
<CommandTextarea
value={command}
onChange={setCommand}
pasteCommandTitle={t('settings.mcp.page.connection.pasteCommandTitle')}
pasteCommandLabel={t('settings.mcp.page.connection.pasteCommand')}
pasteSuccess={(count) => t('settings.mcp.page.toast.pastedArgumentsCount', { count })}
clipboardReadFailed={t('settings.mcp.page.toast.clipboardReadFailed')}
preview={(count) => t('settings.mcp.page.connection.previewArgs', { count })}
onDetectUrl={handleDetectedUrl}
/>
) : (
<Input
value={url}
onChange={(e) => setUrl(e.target.value)}
onChange={(e) => handleUrlChange(e.target.value)}
placeholder={t('settings.mcp.page.connection.serverUrlPlaceholder')}
className="font-mono typography-meta"
/>
)}
<p className="typography-micro text-muted-foreground">
{mcpType === 'local'
? t('settings.mcp.page.connection.hintCommand')
: t('settings.mcp.page.connection.hintLink')}
</p>
</SettingsSection>
{mcpType === 'remote' && (
@@ -1605,7 +1734,9 @@ export const McpPage: React.FC = () => {
<div className="flex items-center gap-1.5 text-left">
<span className="typography-ui-label font-normal text-foreground">{t('settings.mcp.page.advanced.configure')}</span>
<span className="typography-micro text-muted-foreground">
({oauthEnabled ? t('settings.mcp.page.advanced.autoDetect') : t('settings.mcp.page.advanced.custom')} · {headerEntries.length} {t('settings.mcp.page.advanced.headers')}{timeout ? ` · ${timeout}ms` : ''})
{/* OAuth left the form, so the summary stops reporting a
setting the user can no longer see. */}
({headerEntries.length} {t('settings.mcp.page.advanced.headers')}{timeout ? ` · ${timeout}ms` : ''})
</span>
</div>
{isAdvancedRemoteOptionsOpen ? (
@@ -1667,61 +1798,6 @@ export const McpPage: React.FC = () => {
/>
</div>
<div className="space-y-3">
<SettingsCheckboxRow
checked={oauthEnabled}
onChange={setOauthEnabled}
label={t('settings.mcp.page.advanced.oauthAutoDetection')}
ariaLabel={t('settings.mcp.page.advanced.oauthAutoDetectionAria')}
info={t('settings.mcp.page.advanced.oauthHint')}
/>
<div className="grid gap-3 @xl:grid-cols-2">
<Input
value={oauthClientId}
onChange={(e) => setOauthClientId(e.target.value)}
placeholder={t('settings.mcp.page.advanced.oauthClientIdPlaceholder')}
className="font-mono typography-meta"
disabled={!oauthEnabled}
data-bwignore="true"
data-1p-ignore="true"
/>
<Input
value={oauthClientSecret}
onChange={(e) => setOauthClientSecret(e.target.value)}
placeholder={t('settings.mcp.page.advanced.oauthClientSecretPlaceholder')}
className="font-mono typography-meta"
disabled={!oauthEnabled}
data-bwignore="true"
data-1p-ignore="true"
/>
<Input
value={oauthScope}
onChange={(e) => setOauthScope(e.target.value)}
placeholder={t('settings.mcp.page.advanced.oauthScopesPlaceholder')}
className="font-mono typography-meta"
disabled={!oauthEnabled}
data-bwignore="true"
data-1p-ignore="true"
/>
<Input
value={oauthRedirectUri}
onChange={(e) => setOauthRedirectUri(e.target.value)}
placeholder={t('settings.mcp.page.advanced.oauthRedirectUriPlaceholder')}
className="font-mono typography-meta"
disabled={!oauthEnabled}
data-bwignore="true"
data-1p-ignore="true"
/>
</div>
{suggestedRedirectUri && (
<p className="typography-micro text-muted-foreground">
{t('settings.mcp.page.advanced.oauthCallbackHint')}
<span className="mt-1 block break-all font-mono text-foreground/80">{suggestedRedirectUri}</span>
</p>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
@@ -1730,6 +1806,7 @@ export const McpPage: React.FC = () => {
<SettingsSection
title={t('settings.mcp.page.env.title')}
description={t('settings.mcp.page.env.description')}
titleAccessory={
envEntries.length > 0 ? (
<span className="typography-micro text-muted-foreground font-normal">
@@ -1821,7 +1898,7 @@ export const McpPage: React.FC = () => {
}}
placeholder={'{\n "mcpServers": {\n "postgres": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-postgres"]\n }\n }\n}'}
rows={8}
className="font-mono typography-meta resize-y"
className="font-mono typography-meta"
spellCheck={false}
data-bwignore="true"
data-1p-ignore="true"
@@ -8,10 +8,10 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useMcpConfigStore, type McpDraft, type McpServerConfig } from '@/stores/useMcpConfigStore';
import { selectMcpServersForDirectory, useMcpConfigStore, type McpDraft, type McpServerConfig } from '@/stores/useMcpConfigStore';
import { useShallow } from 'zustand/react/shallow';
import { useMcpStore } from '@/stores/useMcpStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { isMobileDeviceViaCSS } from '@/lib/device';
import { cn } from '@/lib/utils';
import { toast } from '@/components/ui';
@@ -65,9 +65,8 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
const { t } = useI18n();
const bgClass = 'bg-background';
const { mcpServers, selectedMcpName, setSelectedMcp, setMcpDraft, loadMcpConfigs, deleteMcp } =
const { selectedMcpName, setSelectedMcp, setMcpDraft, loadMcpConfigs, deleteMcp } =
useMcpConfigStore(useShallow((s) => ({
mcpServers: s.mcpServers,
selectedMcpName: s.selectedMcpName,
setSelectedMcp: s.setSelectedMcp,
setMcpDraft: s.setMcpDraft,
@@ -75,8 +74,11 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
deleteMcp: s.deleteMcp,
})));
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(currentDirectory ?? null));
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const mcpServers = useMcpConfigStore((state) => selectMcpServersForDirectory(state, settingsDirectory));
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(settingsDirectory));
const refreshStatus = useMcpStore((state) => state.refresh);
const getErrorForDirectory = useMcpStore((state) => state.getErrorForDirectory);
@@ -96,8 +98,8 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
);
React.useEffect(() => {
void loadMcpConfigs();
}, [loadMcpConfigs]);
void loadMcpConfigs({ directory: settingsDirectory });
}, [loadMcpConfigs, settingsDirectory]);
const handleRefresh = React.useCallback(() => {
if (isRefreshingStatus) return;
@@ -106,17 +108,17 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
const minSpinPromise = new Promise((resolve) => setTimeout(resolve, 500));
Promise.all([
refreshStatus({ directory: currentDirectory, silent: true }),
refreshStatus({ directory: settingsDirectory, silent: true }),
minSpinPromise,
]).then(() => {
const error = getErrorForDirectory(currentDirectory);
const error = getErrorForDirectory(settingsDirectory);
if (error) {
toast.error(error);
}
}).finally(() => {
setIsRefreshingStatus(false);
});
}, [currentDirectory, getErrorForDirectory, isRefreshingStatus, refreshStatus]);
}, [getErrorForDirectory, isRefreshingStatus, refreshStatus, settingsDirectory]);
const handleCreateNew = () => {
const baseName = 'new-mcp-server';
@@ -151,7 +153,7 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
const handleDelete = async () => {
if (!deleteTarget) return;
setIsDeleting(true);
const result = await deleteMcp(deleteTarget.name);
const result = await deleteMcp(deleteTarget.name, settingsDirectory);
if (result.ok) {
if (result.reloadFailed) {
toast.warning(result.message || `MCP server "${deleteTarget.name}" deleted, but OpenCode reload failed`, {
@@ -0,0 +1,305 @@
import { runtimeFetch } from '@/lib/runtime-fetch';
import { applyPendingOpenCodeRestart } from '@/lib/opencode/deferredRestart';
import { getRuntimeApiBaseUrl, getRuntimeKey } from '@/lib/runtime-switch';
import { openExternalUrl } from '@/lib/url';
import { focusDesktopWindow, isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
import { useMcpConfigStore } from '@/stores/useMcpConfigStore';
import { useMcpStore } from '@/stores/useMcpStore';
import { MCP_OAUTH_CALLBACK_PATH, parseMcpOAuthCallbackStateKey } from './mcpOAuth';
/**
* Starting MCP authorization, for every surface that offers it.
*
* A server in `needs_auth` cannot be fixed by reconnecting: `POST /mcp/:name/connect`
* just repeats the attempt that produced `needs_auth` in the first place. The
* flow OpenCode expects is explicit ask for an authorization URL, send the
* user to it, then hand the returned code back:
*
* POST /mcp/:name/auth { authorizationUrl, oauthState }
* (user authorises in a browser)
* POST /mcp/:name/auth/callback status
*
* OpenCode does not open the browser for this flow; that is the caller's job.
*
* The redirect URI matters as much as the call. Without one of ours in the
* server's config, OpenCode falls back to its own loopback listener on
* 127.0.0.1 which only works when the browser runs on the same machine as
* the OpenCode process. For a remote or web client the callback would simply
* never arrive, so the first authorization writes our own callback URL into
* the config before asking for the URL.
*/
type McpAuthorizationStart = {
authorizationUrl: string;
/** False when the runtime refused to open a browser; the caller then offers a manual paste. */
opened: boolean;
/**
* True when OpenCode runs the whole flow itself over its fixed loopback
* listener: it opened the browser, waits for the callback, and exchanges the
* code. There is no URL to display and no state to correlate callers watch
* runtime status until it turns `connected`.
*/
nativeFlow?: boolean;
/**
* Native flow only: resolves when OpenCode finishes the whole exchange (or
* rejects when it fails) the precise "authorization is over" signal, since
* runtime status alone cannot distinguish a completed reauthorization from
* the still-connected state it started in.
*/
completion?: Promise<void>;
};
class McpAuthorizationError extends Error {}
/**
* The callback lands in the system browser, which is a different surface from
* the desktop app. Recording where the flow began lets the callback page hand
* control back correctly: a browser session returns to the app it is already
* showing, while the desktop shell has to be raised through its own deep link.
*
* This travels with the pending context, not in the redirect URI. That URI is
* written into the server's config once and never rewritten, so a marker
* encoded there would be frozen at whatever runtime happened to authorise
* first a desktop user would keep being sent to the web UI forever.
*/
export const MCP_OAUTH_ORIGIN_DESKTOP = 'desktop';
/**
* Stable for a given server, whatever session is open.
*
* It used to carry the directory as well, which made the address different for
* every worktree: switching sessions produced a new value, so the config was
* rewritten and OpenCode reloaded in front of the user. The directory is not
* needed here authorization is not per-directory and the pending context
* parked under the OAuth `state` carries it for the completion call.
*
* The server name stays. It never varies for a given entry, since the redirect
* lives in that entry's own config, and it lets the callback page identify the
* server straight from the URL rather than depending solely on server-side
* memory surviving the reload this very write triggers.
*/
export const buildMcpAuthorizationRedirectUri = (name: string): string => {
if (typeof window === 'undefined') {
throw new McpAuthorizationError('No browser context to build a callback URL from');
}
const url = new URL(MCP_OAUTH_CALLBACK_PATH, getRuntimeApiBaseUrl() || window.location.origin);
url.searchParams.set('server', name);
return url.toString();
};
/**
* Correlates the eventual browser redirect with the server it belongs to. The
* callback page has only the OAuth `state` to go on, so the pair is parked
* server-side under that key.
*/
const queuePendingContext = async (input: {
state: string;
name: string;
directory?: string | null;
origin: string | null;
}): Promise<void> => {
const response = await runtimeFetch('/api/mcp/auth/pending', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
state: input.state,
name: input.name,
directory: input.directory?.trim() ? input.directory.trim() : null,
origin: input.origin,
}),
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new McpAuthorizationError(payload?.error || 'Failed to prepare the MCP authorization callback');
}
};
const clearPendingContext = async (state: string | null): Promise<void> => {
if (!state) return;
await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(state)}`, { method: 'DELETE' })
.catch(() => undefined);
};
/**
* One-time migration for the native desktop flow: earlier versions wrote this
* app's per-launch callback URL into the server's config, and OpenCode derives
* its listener from that field pointed at OUR port, it either fails to bind
* or the callback lands on a flow that never registered it. Clearing the field
* returns OpenCode to its fixed default port. Applied immediately when the
* write gets queued behind Apply & Restart, for the same reason as the
* callback-URL write below: authorization runs against the live runtime.
*/
const clearCustomRedirectUriForNativeFlow = async (name: string): Promise<void> => {
if (!useMcpConfigStore.getState().getMcpByName(name)) {
await useMcpConfigStore.getState().loadMcpConfigs();
}
const configStore = useMcpConfigStore.getState();
const existing = configStore.getMcpByName(name);
const currentOAuth = existing && 'oauth' in existing && existing.oauth ? existing.oauth : null;
if (!existing || !currentOAuth?.redirectUri) return;
const saved = await configStore.updateMcp(name, {
oauthEnabled: true,
oauthClientId: currentOAuth.clientId ?? '',
oauthClientSecret: currentOAuth.clientSecret ?? '',
oauthScope: currentOAuth.scope ?? '',
oauthRedirectUri: '',
});
if (!saved.ok) {
throw new McpAuthorizationError(saved.message || 'Failed to reset the authorization callback URL');
}
if (saved.restartDeferred) {
const applied = await applyPendingOpenCodeRestart();
if (!applied.ok) {
throw new McpAuthorizationError(
applied.requiresManualRestart
? 'The callback settings changed, but OpenCode must be restarted manually before authorization can start.'
: 'Failed to apply the callback settings. Use Apply & Restart, then authorize again.',
);
}
}
};
/** How long the user plausibly spends authorising before giving up on them. */
const AUTHORIZATION_WATCH_MS = 3 * 60_000;
const AUTHORIZATION_POLL_MS = 1_500;
const waitForAuthorizationThenFocus = async (name: string, directory: string | null): Promise<void> => {
const deadline = Date.now() + AUTHORIZATION_WATCH_MS;
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, AUTHORIZATION_POLL_MS));
try {
await useMcpStore.getState().refresh({ directory, silent: true });
} catch {
continue;
}
const status = useMcpStore.getState().getStatusForDirectory(directory)[name]?.status;
if (status === 'connected') {
void focusDesktopWindow();
return;
}
}
};
export const startMcpAuthorization = async (input: {
name: string;
directory?: string | null;
}): Promise<McpAuthorizationStart> => {
const { name, directory } = input;
let queuedState: string | null = null;
// Runtimes where the system browser provably lives on the same machine as
// OpenCode — desktop with the LOCAL embedded server, and VS Code (the
// extension always spawns its own local OpenCode): OpenCode's own flow works
// end-to-end over its FIXED loopback port (19876) — no config writes, no
// OpenCode restarts, no dependence on this app's per-launch port. The custom
// callback URL below stays for every case where the browser cannot reach
// OpenCode's loopback: remote instances, hosted web, mobile — and the plain
// web runtime too, because same-origin says nothing about the browser being
// on the server's machine.
if (isVSCodeRuntime() || (isDesktopShell() && getRuntimeKey() === 'local')) {
await clearCustomRedirectUriForNativeFlow(name);
const completion = useMcpStore.getState().authenticate(name, directory ?? null);
completion
.then(() => focusDesktopWindow())
.catch(() => {
// Recorded as a runtime diagnostic by the store; the status card and
// the caller's completion handling surface it.
});
return { authorizationUrl: '', opened: true, nativeFlow: true, completion };
}
try {
{
// The config has to be loaded before its absence can mean anything. On
// the first authorization after launch the store is often still empty,
// and reading it then reported "no redirect URI" for a server that had
// one — so the config was rewritten needlessly and OpenCode reloaded in
// front of the user for no reason.
if (!useMcpConfigStore.getState().getMcpByName(name)) {
await useMcpConfigStore.getState().loadMcpConfigs();
}
const configStore = useMcpConfigStore.getState();
const existing = configStore.getMcpByName(name);
// `oauth: false` means the user disabled it explicitly.
const currentOAuth = existing && 'oauth' in existing && existing.oauth
? existing.oauth
: null;
// Rewritten when it does not match the callback we would receive right
// now — not merely when it is missing.
//
// The desktop app's loopback port changes between launches, so a stored
// redirect from an earlier session points at a port nothing serves any
// more: the provider redirects into the void and authorization never
// completes. Comparing instead of checking for absence also means the
// config is left alone — and OpenCode is not reloaded — whenever the
// stored value is already right, which is every run after the first.
const desiredRedirectUri = buildMcpAuthorizationRedirectUri(name);
if (existing && currentOAuth?.redirectUri !== desiredRedirectUri) {
const saved = await configStore.updateMcp(name, {
oauthEnabled: true,
oauthClientId: currentOAuth?.clientId ?? '',
oauthClientSecret: currentOAuth?.clientSecret ?? '',
oauthScope: currentOAuth?.scope ?? '',
oauthRedirectUri: desiredRedirectUri,
});
if (!saved.ok) {
throw new McpAuthorizationError(
saved.message || 'Failed to save the authorization callback URL',
);
}
// Config mutations accumulate behind Apply & Restart now, but the
// authorization flow runs against the LIVE OpenCode runtime: with the
// write still queued, OpenCode hands out its own loopback redirect and
// the callback never reaches us. The user just clicked Authorize —
// explicit intent — so apply the queued changes right away and start
// the flow against the runtime that actually has our callback URL.
if (saved.restartDeferred) {
const applied = await applyPendingOpenCodeRestart();
if (applied.requiresManualRestart) {
throw new McpAuthorizationError(
'The callback URL was saved, but OpenCode must be restarted manually before authorization can start.',
);
}
if (!applied.ok) {
throw new McpAuthorizationError(
'Failed to apply the saved callback URL. Use Apply & Restart, then authorize again.',
);
}
}
}
}
const authorizationUrl = await useMcpStore.getState().startAuth(name, directory ?? null);
const state = parseMcpOAuthCallbackStateKey(new URL(authorizationUrl).searchParams);
if (state) {
queuedState = state;
await queuePendingContext({
state,
name,
directory,
origin: isDesktopShell() ? MCP_OAUTH_ORIGIN_DESKTOP : null,
});
}
const opened = await openExternalUrl(authorizationUrl);
// The desktop app raises itself once the server reports success, rather
// than waiting for the browser to hand control back. A browser will not
// follow a custom-protocol link without a user gesture, and the completion
// page has none — so the return trip cannot start from there.
if (opened && isDesktopShell()) {
void waitForAuthorizationThenFocus(name, directory ?? null);
}
return { authorizationUrl, opened };
} catch (error) {
// A parked context whose flow never started would later resolve a stale
// server for an unrelated callback.
await clearPendingContext(queuedState);
throw error;
}
};
@@ -9,6 +9,7 @@ import { Icon } from "@/components/icon/Icon";
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { InstanceServiceUrls } from './InstanceServiceUrls';
import {
SettingsSection,
SETTINGS_BRAND_TITLE_CLASS,
@@ -135,6 +136,7 @@ export const AboutSettings: React.FC<AboutSettingsProps> = ({ initialUpdateDialo
<p>{t('aboutDialog.openChamberVersionLabel', { version: currentVersion })}</p>
<p>{t('aboutDialog.openCodeVersionLabel', { version: openCodeVersion || t('settings.openchamber.about.state.unknown') })}</p>
</div>
<InstanceServiceUrls />
</div>
<div className="flex justify-center">
@@ -278,6 +280,11 @@ export const AboutSettings: React.FC<AboutSettingsProps> = ({ initialUpdateDialo
</div>
)}
<div className="flex flex-col gap-2 border-b border-border/40 px-4 py-3 @xl:flex-row @xl:items-center @xl:justify-between">
<span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.openchamber.about.field.instanceUrls')}</span>
<InstanceServiceUrls />
</div>
<div className="flex items-center gap-4 px-4 py-4">
<a
href={GITHUB_URL}
@@ -0,0 +1,48 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
import { useI18n } from '@/lib/i18n';
import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore';
/**
* Security section for application deep links (obsidian://, notion://, ...)
* that the user chose to always allow from chat. Removing a scheme restores
* the confirmation dialog for it.
*/
export const AppLinkSecuritySettings: React.FC = () => {
const { t } = useI18n();
const trustedSchemes = useAppLinkTrustStore((state) => state.trustedSchemes);
const removeTrustedScheme = useAppLinkTrustStore((state) => state.removeTrustedScheme);
return (
<SettingsSection
title={t('settings.openchamber.appLinks.title')}
description={t('settings.openchamber.appLinks.info')}
>
<div className="space-y-1" data-settings-item="general.app-links">
{trustedSchemes.length === 0 ? (
<p className="typography-meta text-muted-foreground">
{t('settings.openchamber.appLinks.empty')}
</p>
) : (
trustedSchemes.map((scheme) => (
<div key={scheme} className="flex items-center justify-between gap-2 py-0.5">
<span className="min-w-0 truncate font-mono text-[13px]">{`${scheme}://`}</span>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => removeTrustedScheme(scheme)}
className="!font-normal text-muted-foreground hover:text-foreground"
aria-label={t('settings.openchamber.appLinks.removeAria', { scheme: `${scheme}://` })}
>
{t('settings.common.actions.delete')}
</Button>
</div>
))
)}
</div>
</SettingsSection>
);
};
@@ -17,10 +17,13 @@ import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint'
import { updateDesktopSettings } from '@/lib/persistence';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useSelectionStore } from '@/sync/selection-store';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { useI18n } from '@/lib/i18n';
import { parseModelIdentifier } from '@/lib/modelIdentifier';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { isPrimaryMode } from '@/components/chat/mobileControlsUtils';
const getDisplayModel = (
storedModel: string | undefined
@@ -39,9 +42,24 @@ export const DefaultsSettings: React.FC = () => {
const setModel = useConfigStore((state) => state.setModel);
const setAgent = useConfigStore((state) => state.setAgent);
const setCurrentVariant = useConfigStore((state) => state.setCurrentVariant);
const setCurrentVariantOverride = useConfigStore((state) => state.setCurrentVariantOverride);
const setSettingsDefaultModel = useConfigStore((state) => state.setSettingsDefaultModel);
const setSettingsDefaultVariant = useConfigStore((state) => state.setSettingsDefaultVariant);
const setSettingsDefaultAgent = useConfigStore((state) => state.setSettingsDefaultAgent);
// A default describes new sessions. Applying it to the open chat is a
// convenience, not the point, so it stops where the chat carries a choice the
// user made for it — the same pair of signals ModelControls restores from
// (`shouldPreserveManualModelOverride`).
const selectionIsManual = useConfigStore((state) => state.selectionSource === 'manual');
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const getSessionModelSelection = useSelectionStore((state) => state.getSessionModelSelection);
const getSessionAgentSelection = useSelectionStore((state) => state.getSessionAgentSelection);
const chatHasOwnModel = Boolean(
selectionIsManual && currentSessionId && getSessionModelSelection(currentSessionId),
);
const chatHasOwnAgent = Boolean(
selectionIsManual && currentSessionId && getSessionAgentSelection(currentSessionId),
);
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
const providers = useConfigStore((state) => state.providers);
@@ -52,7 +70,7 @@ export const DefaultsSettings: React.FC = () => {
const [defaultAgent, setDefaultAgent] = React.useState<string | undefined>();
const [smallModelUseDefault, setSmallModelUseDefault] = React.useState(true);
const [smallModelOverride, setSmallModelOverride] = React.useState<string | undefined>();
const [smallModelProviders, setSmallModelProviders] = React.useState<string[] | undefined>();
const [smallModelProviders, setSmallModelProviders] = React.useState<string[]>([]);
const [walkthroughModelOverride, setWalkthroughModelOverride] = React.useState<string | undefined>();
const [isLoading, setIsLoading] = React.useState(true);
@@ -147,14 +165,17 @@ export const DefaultsSettings: React.FC = () => {
setDefaultModel(newValue);
setDefaultVariant(undefined);
setSettingsDefaultVariant(undefined);
setCurrentVariant(undefined);
setSettingsDefaultModel(newValue);
if (providerId && modelId) {
const provider = providers.find((p) => p.id === providerId);
if (provider) {
setProvider(providerId);
setModel(modelId);
if (!chatHasOwnModel) {
setCurrentVariant(undefined);
if (providerId && modelId) {
const provider = providers.find((p) => p.id === providerId);
if (provider) {
setProvider(providerId);
setModel(modelId);
}
}
}
@@ -172,7 +193,7 @@ export const DefaultsSettings: React.FC = () => {
console.warn('Failed to save default model:', error);
}
},
[providers, setCurrentVariant, setModel, setProvider, setSettingsDefaultModel, setSettingsDefaultVariant]
[chatHasOwnModel, providers, setCurrentVariant, setModel, setProvider, setSettingsDefaultModel, setSettingsDefaultVariant]
);
const DEFAULT_VARIANT_VALUE = '__default__';
@@ -189,7 +210,9 @@ export const DefaultsSettings: React.FC = () => {
const newValue = variant === DEFAULT_VARIANT_VALUE ? undefined : variant || undefined;
setDefaultVariant(newValue);
setSettingsDefaultVariant(newValue);
setCurrentVariant(newValue);
if (!chatHasOwnModel) {
setCurrentVariantOverride(newValue ?? null, newValue);
}
try {
await updateDesktopSettings({ defaultVariant: newValue ?? '' });
@@ -197,7 +220,7 @@ export const DefaultsSettings: React.FC = () => {
console.warn('Failed to save default variant:', error);
}
},
[setCurrentVariant, setSettingsDefaultVariant]
[chatHasOwnModel, setCurrentVariantOverride, setSettingsDefaultVariant]
);
const handleAgentChange = React.useCallback(
@@ -206,7 +229,7 @@ export const DefaultsSettings: React.FC = () => {
setDefaultAgent(newValue);
setSettingsDefaultAgent(newValue);
if (agentName) {
if (agentName && !chatHasOwnAgent) {
setAgent(agentName);
}
@@ -216,7 +239,7 @@ export const DefaultsSettings: React.FC = () => {
console.warn('Failed to save default agent:', error);
}
},
[setAgent, setSettingsDefaultAgent]
[chatHasOwnAgent, setAgent, setSettingsDefaultAgent]
);
const handleSmallModelUseDefaultChange = React.useCallback(
@@ -274,13 +297,12 @@ export const DefaultsSettings: React.FC = () => {
() => getDisplayModel(walkthroughModelOverride),
[walkthroughModelOverride]
);
React.useEffect(() => {
// Both pickers filter by the same authenticated-provider list, so either
// one being open is reason enough to fetch it.
// Both pickers filter by the same authenticated-provider list, and the
// walkthrough picker is always visible, so this is always worth fetching.
if (smallModelProviders !== undefined) return;
// Both pickers offer the same providers — the walkthrough runs through the
// small model — and the walkthrough picker is always visible, so this is
// always worth fetching. The server answers with the providers it has a
// credential and an endpoint for, including plugin-registered ones that
// exist only inside the running OpenCode.
let cancelled = false;
(async () => {
try {
@@ -291,13 +313,13 @@ export const DefaultsSettings: React.FC = () => {
setSmallModelProviders(payload.authenticatedProviders.filter((id): id is string => typeof id === 'string'));
}
} catch {
// leave undefined — picker falls back to showing all providers
// Fail closed: never offer providers whose credentials were not verified.
}
})();
return () => {
cancelled = true;
};
}, [smallModelProviders]);
}, []);
const availableVariants = React.useMemo(() => {
if (!parsedModel.providerId || !parsedModel.modelId) return [];
@@ -316,12 +338,14 @@ export const DefaultsSettings: React.FC = () => {
if (!supportsVariants && defaultVariant) {
setDefaultVariant(undefined);
setSettingsDefaultVariant(undefined);
setCurrentVariant(undefined);
if (!chatHasOwnModel) {
setCurrentVariant(undefined);
}
updateDesktopSettings({ defaultVariant: '' }).catch(() => {
// best effort
});
}
}, [defaultVariant, setCurrentVariant, setSettingsDefaultVariant, supportsVariants]);
}, [chatHasOwnModel, defaultVariant, setCurrentVariant, setSettingsDefaultVariant, supportsVariants]);
if (isLoading) {
return null;
@@ -391,6 +415,7 @@ export const DefaultsSettings: React.FC = () => {
<AgentSelector
agentName={defaultAgent || ''}
onChange={handleAgentChange}
filter={(agent) => isPrimaryMode(agent.mode)}
className={SETTINGS_CUSTOM_TRIGGER_CLASS}
/>
</SettingsFieldRow>
@@ -0,0 +1,61 @@
import React from "react";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n";
import { useGitHubAuthStore } from "@/stores/useGitHubAuthStore";
import { GitHubSettings } from "./GitHubSettings";
const serverAuthState = useGitHubAuthStore.getInitialState();
const resetServerAuthState = () => {
Object.assign(serverAuthState, {
status: null,
isLoading: false,
hasChecked: false,
});
};
const renderSettings = () =>
renderToStaticMarkup(
<I18nProvider>
<GitHubSettings />
</I18nProvider>,
);
describe("GitHubSettings", () => {
beforeEach(resetServerAuthState);
afterEach(resetServerAuthState);
test("stays hidden during the initial auth status load", () => {
serverAuthState.isLoading = true;
expect(renderSettings()).toBe("");
});
test("stays mounted while a checked status is refreshing, then shows reconnect state", () => {
Object.assign(serverAuthState, {
status: {
connected: true,
user: { login: "octocat" },
},
isLoading: true,
hasChecked: true,
});
const refreshingMarkup = renderSettings();
expect(refreshingMarkup).toContain("octocat");
expect(refreshingMarkup).toContain("Disconnect");
Object.assign(serverAuthState, {
status: { connected: false },
isLoading: false,
hasChecked: true,
});
const disconnectedMarkup = renderSettings();
expect(disconnectedMarkup).toContain("Not Connected");
expect(disconnectedMarkup).toContain("Connect GitHub");
});
});
@@ -256,7 +256,7 @@ export const GitHubSettings: React.FC = () => {
}
}, [runtimeGitHub, setStatus, t]);
if (isLoading) {
if (isLoading && !hasChecked) {
return null;
}
@@ -0,0 +1,106 @@
import React from 'react';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { openExternalUrl } from '@/lib/url';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/icon/Icon';
type InstanceServiceInfo = {
port: number | null;
tunnelUrl: string | null;
};
type InstanceService = {
key: string;
label: string;
url: string;
};
/**
* Shows the active instance's service URLs (local server port + tunnel URL,
* when a tunnel is active) as labeled buttons that open the URL in the
* browser. The data comes from `/api/system/info`, which the server derives
* from its own runtime state this is what makes each Git-worktree instance
* distinguishable in the UI without reading terminal output.
*
* The section stays hidden when the endpoint is unavailable or reports no
* port/tunnel (e.g. VS Code runtime), so a failed fetch never renders stale
* or wrong URLs.
*/
export const InstanceServiceUrls: React.FC = () => {
const { t } = useI18n();
const [info, setInfo] = React.useState<InstanceServiceInfo | null>(null);
React.useEffect(() => {
let cancelled = false;
const controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined;
const load = async () => {
try {
const response = await runtimeFetch('/api/system/info', {
signal: controller?.signal,
headers: { Accept: 'application/json' },
});
if (!response.ok) return;
const data = await response.json().catch(() => null) as { port?: unknown; tunnelUrl?: unknown } | null;
if (!data || cancelled) return;
const port = typeof data.port === 'number' && Number.isFinite(data.port) && data.port > 0 ? data.port : null;
const tunnelUrl = typeof data.tunnelUrl === 'string' && data.tunnelUrl.trim().length > 0
? data.tunnelUrl.trim()
: null;
setInfo({ port, tunnelUrl });
} catch {
// Best-effort: a failed fetch keeps the section hidden instead of
// showing data we cannot verify.
}
};
void load();
return () => {
cancelled = true;
controller?.abort();
};
}, []);
const services: InstanceService[] = [];
if (info?.port !== null && info?.port !== undefined) {
services.push({
key: 'application',
label: t('settings.openchamber.about.field.applicationUrl'),
url: `http://localhost:${info.port}/`,
});
}
if (info?.tunnelUrl) {
services.push({
key: 'tunnel',
label: t('settings.openchamber.about.field.tunnelUrl'),
url: info.tunnelUrl,
});
}
if (services.length === 0) {
return null;
}
return (
<div className="flex flex-wrap items-center gap-2">
{services.map((service) => (
<Button
key={service.key}
type="button"
variant="outline"
size="sm"
title={service.label}
className="max-w-full gap-1.5 px-2.5"
onClick={() => {
void openExternalUrl(service.url);
}}
>
<Icon name="external-link" className="size-3.5 shrink-0" />
<span className="max-w-64 truncate font-mono typography-micro">{service.url}</span>
</Button>
))}
</div>
);
};
@@ -1,288 +1,136 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
SettingsSection,
SettingsFieldRow,
} from '@/components/sections/shared/SettingsSection';
import { SettingsFieldRow, SettingsSection } from '@/components/sections/shared/SettingsSection';
import { useUIStore } from '@/stores/useUIStore';
import { cn } from '@/lib/utils';
import { updateDesktopSettings } from '@/lib/persistence';
import { isVSCodeRuntime } from '@/lib/desktop';
import {
formatShortcutForDisplay,
getCustomizableShortcutActions,
getEffectiveShortcutCombo,
isRiskyBrowserShortcut,
keyToShortcutToken,
normalizeCombo,
getEffectiveShortcutPrefix,
UNASSIGNED_SHORTCUT,
type ShortcutActionId,
type ShortcutCategory,
type ShortcutCombo,
type CustomizableShortcutAction,
} from '@/lib/shortcuts';
import { useI18n } from '@/lib/i18n';
import { ShortcutRecordingDialog } from './ShortcutRecordingDialog';
const MODIFIER_KEYS = new Set(['shift', 'control', 'alt', 'meta']);
const keyboardEventToCombo = (event: React.KeyboardEvent<HTMLInputElement>): ShortcutCombo | null => {
if (MODIFIER_KEYS.has(event.key.toLowerCase())) {
return null;
}
const parts: string[] = [];
if (event.metaKey || event.ctrlKey) {
parts.push('mod');
}
if (event.shiftKey) {
parts.push('shift');
}
if (event.altKey) {
parts.push('alt');
}
const keyToken = keyToShortcutToken(event.key);
if (!keyToken) {
return null;
}
parts.push(keyToken);
return normalizeCombo(parts.join('+'));
};
const CATEGORIES: ShortcutCategory[] = ['session', 'models', 'panels', 'navigation', 'application'];
export const KeyboardShortcutsSettings: React.FC = () => {
const { t } = useI18n();
const tUnsafe = React.useCallback((key: string) => t(key as Parameters<typeof t>[0]), [t]);
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
const setShortcutOverride = useUIStore((state) => state.setShortcutOverride);
const clearShortcutOverride = useUIStore((state) => state.clearShortcutOverride);
const resetAllShortcutOverrides = useUIStore((state) => state.resetAllShortcutOverrides);
const [editingAction, setEditingAction] = React.useState<CustomizableShortcutAction | null>(null);
const actions = React.useMemo(() => {
const all = getCustomizableShortcutActions();
if (!isVSCodeRuntime()) {
return all;
}
return all.filter((action) => action.id !== 'toggle_prompt_navigator');
return isVSCodeRuntime() ? all.filter((action) => action.id !== 'toggle_prompt_navigator') : all;
}, []);
const actionLabel = React.useCallback((id: string, fallbackLabel: string): string => {
const key = `settings.openchamber.keyboardShortcuts.action.${id}.label`;
const translated = tUnsafe(key);
return translated === key ? fallbackLabel : translated;
}, [tUnsafe]);
const [capturingActionId, setCapturingActionId] = React.useState<string | null>(null);
const [draftByAction, setDraftByAction] = React.useState<Record<string, ShortcutCombo>>({});
const [errorText, setErrorText] = React.useState<string>('');
const [warningText, setWarningText] = React.useState<string>('');
const [pendingOverwrite, setPendingOverwrite] = React.useState<{
actionId: string;
combo: ShortcutCombo;
conflictActionId: string;
} | null>(null);
const persistShortcutOverrides = React.useCallback((nextOverrides: Record<string, ShortcutCombo>) => {
const persist = (nextOverrides: Record<string, ShortcutCombo>) => {
void updateDesktopSettings({ shortcutOverrides: nextOverrides });
}, []);
const findConflict = React.useCallback((actionId: string, combo: ShortcutCombo): string | null => {
const normalized = normalizeCombo(combo);
for (const action of actions) {
if (action.id === actionId) {
continue;
}
const existing = getEffectiveShortcutCombo(action.id, shortcutOverrides);
if (normalizeCombo(existing) === normalized) {
return action.id;
}
}
return null;
}, [actions, shortcutOverrides]);
const saveCombo = React.useCallback((actionId: string, combo: ShortcutCombo) => {
const normalized = normalizeCombo(combo);
const conflictActionId = findConflict(actionId, normalized);
if (conflictActionId) {
setPendingOverwrite({ actionId, combo: normalized, conflictActionId });
setErrorText('');
return;
}
const nextOverrides = { ...shortcutOverrides, [actionId]: normalized };
setShortcutOverride(actionId, normalized);
persistShortcutOverrides(nextOverrides);
setPendingOverwrite(null);
setErrorText('');
setWarningText(isRiskyBrowserShortcut(normalized) ? t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut') : '');
setDraftByAction((current) => {
const rest = { ...current };
delete rest[actionId];
return rest;
});
}, [findConflict, persistShortcutOverrides, setShortcutOverride, shortcutOverrides, t]);
const confirmOverwrite = React.useCallback(() => {
if (!pendingOverwrite) {
return;
}
const nextOverrides = {
...shortcutOverrides,
[pendingOverwrite.conflictActionId]: UNASSIGNED_SHORTCUT,
[pendingOverwrite.actionId]: pendingOverwrite.combo,
};
setShortcutOverride(pendingOverwrite.conflictActionId, UNASSIGNED_SHORTCUT);
setShortcutOverride(pendingOverwrite.actionId, pendingOverwrite.combo);
persistShortcutOverrides(nextOverrides);
setPendingOverwrite(null);
setErrorText('');
setWarningText(isRiskyBrowserShortcut(pendingOverwrite.combo) ? t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut') : '');
setDraftByAction((current) => {
const rest = { ...current };
delete rest[pendingOverwrite.actionId];
return rest;
});
}, [pendingOverwrite, persistShortcutOverrides, setShortcutOverride, shortcutOverrides, t]);
const resetOne = React.useCallback((actionId: string) => {
};
const save = (
actionId: ShortcutActionId,
combo: ShortcutCombo,
replaceActionId?: ShortcutActionId,
) => {
const nextOverrides = { ...shortcutOverrides, [actionId]: combo };
if (replaceActionId) nextOverrides[replaceActionId] = UNASSIGNED_SHORTCUT;
setShortcutOverride(actionId, combo);
if (replaceActionId) setShortcutOverride(replaceActionId, UNASSIGNED_SHORTCUT);
persist(nextOverrides);
};
const resetOne = (actionId: ShortcutActionId) => {
const nextOverrides = { ...shortcutOverrides };
delete nextOverrides[actionId];
clearShortcutOverride(actionId);
persistShortcutOverrides(nextOverrides);
setDraftByAction((current) => {
const rest = { ...current };
delete rest[actionId];
return rest;
});
setPendingOverwrite(null);
setErrorText('');
setWarningText('');
}, [clearShortcutOverride, persistShortcutOverrides, shortcutOverrides]);
persist(nextOverrides);
};
const shortcutDisplay = (action: CustomizableShortcutAction): string => {
const isPrefixStyle = 'prefixStyle' in action && action.prefixStyle;
const combo = isPrefixStyle
? getEffectiveShortcutPrefix(action.id, shortcutOverrides)
: getEffectiveShortcutCombo(action.id, shortcutOverrides);
const formatted = formatShortcutForDisplay(
combo,
t('settings.openchamber.keyboardShortcuts.unassigned'),
);
if (!isPrefixStyle || !combo || combo === UNASSIGNED_SHORTCUT) return formatted;
const suffix = action.id === 'switch_session_tab'
? t('settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix')
: t('settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix');
return `${formatted}${suffix}`;
};
return (
<SettingsSection
settingsItem="shortcuts.keyboard-shortcuts"
title={t('settings.openchamber.keyboardShortcuts.title')}
divider={false}
info={t('settings.openchamber.keyboardShortcuts.tooltip')}
headerAction={(
<Button
type="button"
variant="outline"
size="xs"
className="!font-normal"
onClick={() => {
resetAllShortcutOverrides();
persistShortcutOverrides({});
setDraftByAction({});
setPendingOverwrite(null);
setErrorText('');
setWarningText('');
}}
>
{t('settings.openchamber.keyboardShortcuts.actions.resetAll')}
</Button>
)}
>
{(errorText || warningText || pendingOverwrite) && (
<div className="mb-2 space-y-2">
{pendingOverwrite && (
<div className="rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3 flex flex-col @xl:flex-row @xl:items-center justify-between gap-3">
<span className="typography-meta text-foreground">
{t('settings.openchamber.keyboardShortcuts.overwritePrompt')}
</span>
<div className="flex gap-2 shrink-0">
<Button type="button" size="xs" className="!font-normal" onClick={confirmOverwrite}>{t('settings.openchamber.keyboardShortcuts.actions.overwrite')}</Button>
<Button type="button" size="xs" className="!font-normal" variant="ghost" onClick={() => setPendingOverwrite(null)}>{t('settings.common.actions.cancel')}</Button>
</div>
<>
{CATEGORIES.map((category, categoryIndex) => {
const categoryActions = actions.filter((action) => action.category === category);
if (categoryActions.length === 0) return null;
return (
<SettingsSection
key={category}
settingsItem={categoryIndex === 0 ? 'shortcuts.keyboard-shortcuts' : undefined}
title={t(`settings.openchamber.keyboardShortcuts.category.${category}`)}
divider={categoryIndex !== 0}
info={categoryIndex === 0 ? t('settings.openchamber.keyboardShortcuts.tooltip') : undefined}
headerAction={categoryIndex === 0 ? (
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => {
resetAllShortcutOverrides();
persist({});
}}>
{t('settings.openchamber.keyboardShortcuts.actions.resetAll')}
</Button>
) : undefined}
>
<div className="space-y-2">
{categoryActions.map((action) => (
<SettingsFieldRow key={action.id} label={t(action.settingsLabelKey)}>
<kbd
className="min-w-32 rounded-md border border-border bg-muted px-2 py-1 text-center typography-meta font-mono text-foreground"
>
{shortcutDisplay(action)}
</kbd>
<Button
type="button"
variant="secondary"
size="xs"
className="!font-normal"
onClick={() => setEditingAction(action)}
>
{t('settings.openchamber.keyboardShortcuts.actions.edit')}
</Button>
{action.id in shortcutOverrides ? (
<Button
type="button"
variant="ghost"
size="xs"
className="!font-normal"
onClick={() => resetOne(action.id)}
>
{t('settings.common.actions.reset')}
</Button>
) : null}
</SettingsFieldRow>
))}
</div>
)}
{errorText && (
<div className="rounded-lg border border-[var(--status-error-border)] bg-[var(--status-error-background)] p-3 typography-meta text-foreground">
{errorText}
</div>
)}
{warningText && (
<div className="rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3 typography-meta text-foreground">
{warningText}
</div>
)}
</div>
)}
<div>
{actions.map((action, index) => {
const effective = getEffectiveShortcutCombo(action.id, shortcutOverrides);
const draft = draftByAction[action.id];
const displayCombo = draft ?? effective;
const hasDraft = typeof draft === 'string' && normalizeCombo(draft) !== normalizeCombo(effective);
return (
<div key={action.id} className={cn("py-1.5", index > 0 && "border-t border-border/40")}>
<SettingsFieldRow
label={actionLabel(action.id, action.label)}
alignEnd={false}
>
<Input
readOnly
value={capturingActionId === action.id ? t('settings.openchamber.keyboardShortcuts.field.pressKeys') : formatShortcutForDisplay(displayCombo)}
onFocus={() => {
setCapturingActionId(action.id);
setErrorText('');
}}
onBlur={() => {
if (capturingActionId === action.id) {
setCapturingActionId(null);
}
}}
onKeyDown={(event) => {
event.preventDefault();
event.stopPropagation();
if (event.key === 'Escape') {
setCapturingActionId(null);
return;
}
const combo = keyboardEventToCombo(event);
if (!combo) {
return;
}
setDraftByAction((current) => ({
...current,
[action.id]: combo,
}));
setCapturingActionId(null);
setPendingOverwrite(null);
setErrorText('');
}}
className="h-7 w-40 min-w-0 typography-ui-label text-center"
/>
<Button
type="button"
variant="secondary"
size="xs"
className="!font-normal"
onClick={() => {
const next = draftByAction[action.id];
if (!next) {
setErrorText(t('settings.openchamber.keyboardShortcuts.error.captureFirst'));
return;
}
saveCombo(action.id, next);
}}
disabled={!hasDraft}
>
{t('settings.common.actions.saveChanges')}
</Button>
<Button type="button" size="xs" className="!font-normal" variant="ghost" onClick={() => resetOne(action.id)}>
{t('settings.common.actions.reset')}
</Button>
</SettingsFieldRow>
</div>
);
})}
</div>
</SettingsSection>
</SettingsSection>
);
})}
<ShortcutRecordingDialog
action={editingAction}
overrides={shortcutOverrides}
onSave={save}
onOpenChange={(open) => {
if (!open) setEditingAction(null);
}}
/>
</>
);
};
@@ -152,13 +152,13 @@ export const NotificationSettings: React.FC = () => {
field: 'title' | 'message',
value: string,
) => {
setNotificationTemplates({
...notificationTemplates,
setNotificationTemplates((current) => ({
...current,
[event]: {
...notificationTemplates[event],
...current[event],
[field]: value,
},
});
}));
};
const base64UrlToUint8Array = (base64Url: string): Uint8Array<ArrayBuffer> => {
@@ -3,6 +3,7 @@ import { OpenChamberVisualSettings } from './OpenChamberVisualSettings';
import { AboutSettings } from './AboutSettings';
import { SessionRetentionSettings } from './SessionRetentionSettings';
import { PasskeySettings } from './PasskeySettings';
import { AppLinkSecuritySettings } from './AppLinkSecuritySettings';
import { DefaultsSettings } from './DefaultsSettings';
import { GitSettings } from './GitSettings';
import { NotificationSettings } from './NotificationSettings';
@@ -10,6 +11,7 @@ import { GitHubSettings } from './GitHubSettings';
import { VoiceSettings } from './VoiceSettings';
import { TunnelSettings } from './TunnelSettings';
import { OpenCodeCliSettings } from './OpenCodeCliSettings';
import { OpenChamberToolsSettings } from './OpenChamberToolsSettings';
import { DesktopNetworkSettings } from './DesktopNetworkSettings';
import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
@@ -52,7 +54,9 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
<DefaultsSettings />
{showDesktopNetworkSettings && <DesktopNetworkSettings />}
{!isVSCode && <OpenCodeCliSettings />}
{!isVSCode && <OpenChamberToolsSettings />}
<SessionRetentionSettings />
<AppLinkSecuritySettings />
{isWebRuntime() && !isDesktopShell() && !isVSCode && !isCapacitorApp() && <PasskeySettings />}
{showAbout && <AboutSettings />}
</SettingsPageLayout>
@@ -143,11 +147,13 @@ const GeneralSectionContent: React.FC = () => {
<>
{showDesktopNetworkSettings && <DesktopNetworkSettings />}
{showPasskeySettings && <PasskeySettings />}
<AppLinkSecuritySettings />
{!isVSCode && <OpenCodeCliSettings />}
{!isVSCode && <OpenChamberToolsSettings />}
<OpenChamberVisualSettings visibleSettings={[
'fileEditorKeymap',
...(!isVSCode ? ['sessionTabs' as const] : []),
'autoSaveEnabled',
'expandedEditorToolbar',
...(!isVSCode ? ['terminalQuickKeys' as const] : []),
...(!isVSCode ? ['terminalShell' as const] : []),
...(!isVSCode ? ['terminalLoginShell' as const] : []),
@@ -0,0 +1,101 @@
import * as React from 'react';
import {
SettingsSection,
SettingsCheckboxRow,
SETTINGS_OPTION_STACK_CLASS,
} from '@/components/sections/shared/SettingsSection';
import { recordDeferredOpenCodeRestart } from '@/lib/opencode/deferredRestart';
import { updateDesktopSettings } from '@/lib/persistence';
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
import { useUIStore } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n';
/**
* Which OpenChamber capabilities agents are given.
*
* Each entry is one tool the managed OpenCode child is handed, so the choices
* belong together and not under the CLI's own configuration the binary path
* is about which OpenCode runs, these are about what it can do.
*
* A toggle is written immediately but only reaches agents once OpenCode
* restarts, so each one records a pending restart rather than implying the
* change is already live.
*/
export const OpenChamberToolsSettings: React.FC = () => {
const { t } = useI18n();
const agentControlToolEnabled = useUIStore((state) => state.agentControlToolEnabled);
const setAgentControlToolEnabled = useUIStore((state) => state.setAgentControlToolEnabled);
const agentWebToolEnabled = useUIStore((state) => state.agentWebToolEnabled);
const setAgentWebToolEnabled = useUIStore((state) => state.setAgentWebToolEnabled);
const agentMemoryToolEnabled = useUIStore((state) => state.agentMemoryToolEnabled);
// Absent, not merely off: the feature is finished but unreleased, and a
// visible switch invites turning on something that was never announced.
const agentMemoryAvailable = useUIStore((state) => state.agentMemoryFeatureAvailable);
const setAgentMemoryToolEnabled = useUIStore((state) => state.setAgentMemoryToolEnabled);
const handleAgentControlToolChange = React.useCallback((enabled: boolean) => {
setAgentControlToolEnabled(enabled);
void updateDesktopSettings({ agentControlToolEnabled: enabled });
recordDeferredOpenCodeRestart('cli', { id: 'agent-control-tool' });
}, [setAgentControlToolEnabled]);
const handleAgentWebToolChange = React.useCallback((enabled: boolean) => {
setAgentWebToolEnabled(enabled);
void updateDesktopSettings({ agentWebToolEnabled: enabled });
recordDeferredOpenCodeRestart('cli', { id: 'agent-web-tool' });
}, [setAgentWebToolEnabled]);
// Turning memory off removes the whole feature, not just the tool: the panel
// tab goes with it and sessions stop being given the index. Showing the user
// what is stored would be pointless once the agent can no longer manage it.
const handleAgentMemoryToolChange = React.useCallback((enabled: boolean) => {
setAgentMemoryToolEnabled(enabled);
// Re-read after the write lands, not before. The switch flips the client
// immediately, which makes the panel ask the server straight away — and
// while the setting is still being written the server truthfully answers
// "disabled", which used to leave the tab hidden until a restart.
void updateDesktopSettings({ agentMemoryToolEnabled: enabled })
.finally(() => {
if (enabled) {
void useAgentMemoryStore.getState().refresh();
}
});
recordDeferredOpenCodeRestart('cli', { id: 'agent-memory-tool' });
}, [setAgentMemoryToolEnabled]);
return (
<SettingsSection title={t('settings.openchamber.tools.title')}>
<div className={SETTINGS_OPTION_STACK_CLASS}>
<SettingsCheckboxRow
settingsItem="sessions.agent-control-tool"
checked={agentControlToolEnabled}
onChange={handleAgentControlToolChange}
label={t('settings.openchamber.tools.field.agentControlTool')}
ariaLabel={t('settings.openchamber.tools.field.agentControlToolAria')}
info={t('settings.openchamber.tools.field.agentControlToolInfo')}
/>
<SettingsCheckboxRow
settingsItem="sessions.agent-web-tool"
checked={agentWebToolEnabled}
onChange={handleAgentWebToolChange}
label={t('settings.openchamber.tools.field.agentWebTool')}
ariaLabel={t('settings.openchamber.tools.field.agentWebToolAria')}
info={t('settings.openchamber.tools.field.agentWebToolInfo')}
/>
{agentMemoryAvailable ? (
<SettingsCheckboxRow
settingsItem="sessions.agent-memory-tool"
checked={agentMemoryToolEnabled}
onChange={handleAgentMemoryToolChange}
label={t('settings.openchamber.tools.field.agentMemoryTool')}
ariaLabel={t('settings.openchamber.tools.field.agentMemoryToolAria')}
info={t('settings.openchamber.tools.field.agentMemoryToolInfo')}
/>
) : null}
</div>
</SettingsSection>
);
};
@@ -18,7 +18,6 @@ import {
} from '@/components/ui/select';
import { Icon } from "@/components/icon/Icon";
import {
invokeDesktop,
isDesktopShell,
isVSCodeRuntime,
isWebRuntime,
@@ -33,7 +32,6 @@ import { CODE_FONT_OPTIONS, DEFAULT_MONO_FONT, DEFAULT_UI_FONT, UI_FONT_OPTIONS,
import { useI18n, type Locale } from '@/lib/i18n';
import { useConfigStore } from '@/stores/useConfigStore';
import { normalizeMobileKeyboardMode, supportsMobileKeyboardResizeContent, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import { getStoredMobileLayoutPreference, setStoredMobileLayoutPreference, type MobileLayoutPreference } from '@/lib/mobileLayoutPreference';
import {
setDirectoryShowHidden,
useDirectoryShowHidden,
@@ -64,6 +62,7 @@ import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import type { TerminalShellOption } from '@/lib/api/types';
import { isTerminalShell } from '@/lib/terminalShell';
import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
import { formatShortcutForDisplay } from '@/lib/shortcuts';
interface Option<T extends string> {
id: T;
@@ -152,17 +151,6 @@ const MOBILE_KEYBOARD_MODE_OPTIONS: Option<MobileKeyboardMode>[] = [
},
];
const MOBILE_LAYOUT_OPTIONS: Array<{ value: MobileLayoutPreference; labelKey: string }> = [
{
value: 'default',
labelKey: 'settings.openchamber.visual.option.mobileLayout.default',
},
{
value: 'new',
labelKey: 'settings.openchamber.visual.option.mobileLayout.new',
},
];
type PwaInstallNameWindow = Window & {
__OPENCHAMBER_SET_PWA_INSTALL_NAME__?: (value: string) => string;
__OPENCHAMBER_SET_PWA_ORIENTATION__?: (value: 'system' | 'portrait' | 'landscape') => 'system' | 'portrait' | 'landscape';
@@ -294,7 +282,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
return mode === 'markdown' ? 'markdown' : 'plain';
};
type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'largeTextPaste' | 'reportUsage' | 'expandedEditorToolbar' | 'autoSaveEnabled';
type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'largeTextPaste' | 'reportUsage' | 'autoSaveEnabled' | 'sessionTabs';
const WINDOW_CONTROLS_POSITION_OPTIONS: Array<{ id: DesktopWindowControlsPosition; labelKey: string }> = [
{ id: 'left', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsLeft' },
@@ -330,6 +318,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const sessionGoalDefaultBudget = useUIStore(state => state.sessionGoalDefaultBudget);
const setSessionGoalDefaultBudget = useUIStore(state => state.setSessionGoalDefaultBudget);
const setShowReasoningTraces = useUIStore(state => state.setShowReasoningTraces);
const streamingAutoFollowEnabled = useUIStore(state => state.streamingAutoFollowEnabled);
const setStreamingAutoFollowEnabled = useUIStore(state => state.setStreamingAutoFollowEnabled);
const collapsibleThinkingBlocks = useUIStore(state => state.collapsibleThinkingBlocks);
const setCollapsibleThinkingBlocks = useUIStore(state => state.setCollapsibleThinkingBlocks);
@@ -343,8 +333,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const promptNavigatorEnabled = useUIStore(state => state.promptNavigatorEnabled);
const setStickyUserHeader = useUIStore(state => state.setStickyUserHeader);
const setPromptNavigatorEnabled = useUIStore(state => state.setPromptNavigatorEnabled);
const expandedEditorToolbar = useUIStore(state => state.expandedEditorToolbar);
const setExpandedEditorToolbar = useUIStore(state => state.setExpandedEditorToolbar);
const autoSaveEnabled = useUIStore(state => state.autoSaveEnabled);
const setAutoSaveEnabled = useUIStore(state => state.setAutoSaveEnabled);
const wideChatLayoutEnabled = useUIStore(state => state.wideChatLayoutEnabled);
@@ -378,6 +366,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const diffLayoutPreference = useUIStore(state => state.diffLayoutPreference);
const setDiffLayoutPreference = useUIStore(state => state.setDiffLayoutPreference);
const showTerminalQuickKeysOnDesktop = useUIStore(state => state.showTerminalQuickKeysOnDesktop);
const sessionTabsEnabled = useUIStore(state => state.sessionTabsEnabled);
const setSessionTabsEnabled = useUIStore(state => state.setSessionTabsEnabled);
const setShowTerminalQuickKeysOnDesktop = useUIStore(state => state.setShowTerminalQuickKeysOnDesktop);
const fileEditorKeymap = useUIStore(state => state.fileEditorKeymap);
const setFileEditorKeymap = useUIStore(state => state.setFileEditorKeymap);
@@ -427,16 +417,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const [themesReloading, setThemesReloading] = React.useState(false);
// macOS-desktop-only vibrancy toggle. Changing it needs a full relaunch
// (vibrancy is a window-creation option), so we persist + restart on save.
const macVibrancySupported = React.useMemo(
() => isDesktopShell() && typeof window !== 'undefined' && window.__OPENCHAMBER_ELECTRON__?.macVibrancySupported === true,
[],
);
const macVibrancyEnabled = typeof window !== 'undefined' && window.__OPENCHAMBER_ELECTRON__?.macVibrancy === true;
const [vibrancyChecked, setVibrancyChecked] = React.useState(macVibrancyEnabled);
const [vibrancyRestarting, setVibrancyRestarting] = React.useState(false);
// macOS-desktop-only dock badge that counts chats with unseen activity.
// The tray sync (mac-only) pumps the count to the main process, so the
// toggle is offered only where it actually has an effect. No relaunch needed.
@@ -538,11 +518,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
void updateDesktopSettings({ draftStartersVisible: enabled });
}, [setDraftStartersVisible]);
const handleExpandedEditorToolbarChange = React.useCallback((enabled: boolean) => {
setExpandedEditorToolbar(enabled);
void updateDesktopSettings({ expandedEditorToolbar: enabled });
}, [setExpandedEditorToolbar]);
const handleCollapsibleUserMessagesChange = React.useCallback((enabled: boolean) => {
setCollapsibleUserMessages(enabled);
void updateDesktopSettings({ collapsibleUserMessages: enabled });
@@ -657,12 +632,11 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const hasThemeSettings = shouldShow('theme') && !isVSCode;
const showWindowControlsPositionSetting = shouldShow('windowControlsPosition') && showWindowControlsPosition;
const hasLocalizationSettings = shouldShow('theme') || shouldShow('timeFormat') || shouldShow('weekStart');
const showMobileLayoutSetting = isMobile && isWebRuntime() && !isDesktopShell() && !isVSCode;
const hasAppearanceSettings = isVSCode
? hasLocalizationSettings
: (shouldShow('theme') || showWindowControlsPositionSetting || showMobileLayoutSetting || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart'));
: (shouldShow('theme') || showWindowControlsPositionSetting || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart'));
const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('editorFontSize') || shouldShow('spacing') || (shouldShow('inputBarOffset') && isMobile);
const hasNavigationSettings = (shouldShow('terminalQuickKeys') && !isMobile) || ((shouldShow('terminalShell') || shouldShow('terminalLoginShell')) && !isVSCode) || shouldShow('fileEditorKeymap') || shouldShow('autoSaveEnabled') || (shouldShow('expandedEditorToolbar') && !isVSCode);
const hasNavigationSettings = (shouldShow('terminalQuickKeys') && !isMobile) || ((shouldShow('terminalShell') || shouldShow('terminalLoginShell')) && !isVSCode) || shouldShow('fileEditorKeymap') || shouldShow('autoSaveEnabled') || (shouldShow('sessionTabs') && !isVSCode && !isMobile);
const hasBehaviorSettings = shouldShow('mermaidRendering')
|| (shouldShow('sessionGoal') && !isVSCode)
|| shouldShow('userMessageRendering')
@@ -753,7 +727,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
? [...terminalLoginShells.filter((shell) => shell !== terminalShell), terminalShell]
: terminalLoginShells.filter((shell) => shell !== terminalShell));
};
const [mobileLayoutPreference, setMobileLayoutPreference] = React.useState<MobileLayoutPreference>(() => getStoredMobileLayoutPreference());
const [pwaInstallName, setPwaInstallName] = React.useState('');
const [pwaOrientation, setPwaOrientation] = React.useState<'system' | 'portrait' | 'landscape'>('system');
const selectedTimeFormatLabel = React.useMemo(() => {
@@ -773,16 +746,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
return option ? tUnsafe(option.labelKey) : undefined;
}, [mobileKeyboardMode, tUnsafe]);
const handleMobileLayoutPreferenceChange = React.useCallback((value: MobileLayoutPreference) => {
if (value === mobileLayoutPreference) {
return;
}
setMobileLayoutPreference(value);
setStoredMobileLayoutPreference(value);
window.location.reload();
}, [mobileLayoutPreference]);
const applyPwaInstallName = React.useCallback(async (value: string) => {
if (typeof window === 'undefined') {
return;
@@ -907,21 +870,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
))}
</SettingsRadioGroup>
{showMobileLayoutSetting && (
<SettingsInset>
<SettingsStackedField label={t('settings.openchamber.visual.section.mobileLayout')}>
<SettingsChipGroup
value={mobileLayoutPreference}
options={MOBILE_LAYOUT_OPTIONS.map((option) => ({
value: option.value,
label: tUnsafe(option.labelKey),
}))}
onChange={handleMobileLayoutPreferenceChange}
aria-label={t('settings.openchamber.visual.section.mobileLayout')}
/>
</SettingsStackedField>
</SettingsInset>
)}
</div>
<div className={SETTINGS_FIELDS_STACK_CLASS}>
@@ -998,36 +946,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</div>
</SettingsTwoColumn>
{macVibrancySupported && (
<SettingsInset settingsItem="appearance.window-transparency" className="flex flex-col gap-1.5">
<SettingsCheckboxRow
checked={vibrancyChecked}
onChange={setVibrancyChecked}
disabled={vibrancyRestarting}
label={t('settings.openchamber.visual.field.macVibrancy')}
info={t('settings.openchamber.visual.field.macVibrancyHint')}
ariaLabel={t('settings.openchamber.visual.field.macVibrancy')}
/>
{vibrancyChecked !== macVibrancyEnabled && (
<div className="pl-6">
<Button
variant="outline"
size="sm"
disabled={vibrancyRestarting}
onClick={() => {
setVibrancyRestarting(true);
void invokeDesktop('desktop_set_vibrancy', { enabled: vibrancyChecked });
}}
>
{vibrancyRestarting
? t('settings.openchamber.visual.actions.restarting')
: t('settings.openchamber.visual.actions.saveAndRestart')}
</Button>
</div>
)}
</SettingsInset>
)}
{dockBadgeSupported && (
<SettingsInset settingsItem="appearance.dock-badge">
<SettingsCheckboxRow
@@ -1299,7 +1217,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
controlClassName="w-full"
>
<Select value={uiFont} onValueChange={(value) => setUiFont(value as UiFontOption)}>
<SelectTrigger aria-label={t('settings.openchamber.visual.field.selectInterfaceFontAria')} size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_TRIGGER_CLASS}>
<SelectTrigger aria-label={t('settings.openchamber.visual.field.selectInterfaceFontAria')} size={SETTINGS_SELECT_SIZE} className={cn(SETTINGS_SELECT_TRIGGER_CLASS, 'max-w-full')}>
<SelectValue>{UI_FONT_OPTIONS.find((option) => option.id === uiFont)?.label}</SelectValue>
</SelectTrigger>
<SelectContent>
@@ -1329,7 +1247,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
controlClassName="w-full"
>
<Select value={monoFont} onValueChange={(value) => setMonoFont(value as MonoFontOption)}>
<SelectTrigger aria-label={t('settings.openchamber.visual.field.selectCodeFontAria')} size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_TRIGGER_CLASS}>
<SelectTrigger aria-label={t('settings.openchamber.visual.field.selectCodeFontAria')} size={SETTINGS_SELECT_SIZE} className={cn(SETTINGS_SELECT_TRIGGER_CLASS, 'max-w-full')}>
<SelectValue>{CODE_FONT_OPTIONS.find((option) => option.id === monoFont)?.label}</SelectValue>
</SelectTrigger>
<SelectContent>
@@ -1370,6 +1288,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
min={50}
max={200}
step={5}
className="w-20"
aria-label={t('settings.openchamber.visual.field.fontSizePercentageAria')}
/>
<span className={SETTINGS_NUMBER_UNIT_CLASS}>%</span>
@@ -1400,6 +1319,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
min={9}
max={52}
step={1}
className="w-20"
/>
<span className={SETTINGS_NUMBER_UNIT_CLASS}>px</span>
<Button size="sm"
@@ -1429,6 +1349,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
min={9}
max={32}
step={1}
className="w-20"
/>
<span className={SETTINGS_NUMBER_UNIT_CLASS}>px</span>
<Button size="sm"
@@ -1463,6 +1384,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
min={50}
max={200}
step={5}
className="w-20"
/>
<span className={SETTINGS_NUMBER_UNIT_CLASS}>%</span>
<Button size="sm"
@@ -1493,6 +1415,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
min={0}
max={100}
step={5}
className="w-20"
/>
<span className={SETTINGS_NUMBER_UNIT_CLASS}>px</span>
<Button size="sm"
@@ -1546,25 +1469,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
settingsItem="appearance.auto-save-enabled"
/>
)}
{shouldShow('expandedEditorToolbar') && !isVSCode && (
<SettingsCheckboxRow
checked={expandedEditorToolbar}
onChange={handleExpandedEditorToolbarChange}
label={t('settings.openchamber.visual.field.expandedEditorToolbar')}
ariaLabel={t('settings.openchamber.visual.field.expandedEditorToolbarAria')}
settingsItem="appearance.expanded-editor-toolbar"
/>
)}
{shouldShow('terminalQuickKeys') && !isMobile && (
<SettingsCheckboxRow
checked={showTerminalQuickKeysOnDesktop}
onChange={setShowTerminalQuickKeysOnDesktop}
label={t('settings.openchamber.visual.field.terminalQuickKeys')}
ariaLabel={t('settings.openchamber.visual.field.terminalQuickKeysAria')}
settingsItem="appearance.terminal-quick-keys"
info={t('settings.openchamber.visual.field.terminalQuickKeysTooltip')}
/>
)}
{showTerminalShellSetting && (
<SettingsStackedField
label={t('settings.openchamber.visual.field.terminalShell')}
@@ -1594,7 +1498,34 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
settingsItem="appearance.terminal-login-shell"
/>
)}
{shouldShow('terminalQuickKeys') && !isMobile && (
<SettingsCheckboxRow
checked={showTerminalQuickKeysOnDesktop}
onChange={setShowTerminalQuickKeysOnDesktop}
label={t('settings.openchamber.visual.field.terminalQuickKeys')}
ariaLabel={t('settings.openchamber.visual.field.terminalQuickKeysAria')}
settingsItem="appearance.terminal-quick-keys"
info={t('settings.openchamber.visual.field.terminalQuickKeysTooltip', {
control: formatShortcutForDisplay('ctrl'),
alt: formatShortcutForDisplay('alt'),
})}
/>
)}
</div>
{shouldShow('sessionTabs') && !isVSCode && !isMobile && (
<SettingsControlGroup
title={t('settings.openchamber.visual.field.sessionTabsGroup')}
settingsItem="appearance.session-tabs"
>
<SettingsCheckboxRow
checked={sessionTabsEnabled}
onChange={setSessionTabsEnabled}
label={t('settings.openchamber.visual.field.sessionTabs')}
ariaLabel={t('settings.openchamber.visual.field.sessionTabsAria')}
info={t('settings.openchamber.visual.field.sessionTabsInfo')}
/>
</SettingsControlGroup>
)}
</SettingsSection>
)}
@@ -1938,6 +1869,20 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
)}
</SettingsSection>
)}
<SettingsSection
title={t('settings.openchamber.visual.section.streaming')}
settingsItem="chat.streaming"
contentClassName={SETTINGS_OPTION_STACK_CLASS}
>
<SettingsCheckboxRow
checked={streamingAutoFollowEnabled}
onChange={setStreamingAutoFollowEnabled}
label={t('settings.openchamber.visual.field.streamingAutoFollow')}
ariaLabel={t('settings.openchamber.visual.field.streamingAutoFollowAria')}
info={t('settings.openchamber.visual.field.streamingAutoFollowInfo')}
settingsItem="chat.streaming-auto-follow"
/>
</SettingsSection>
{(shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || (shouldShow('promptNavigatorEnabled') && !isVSCode) || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('codeBlockLineWrap')) && (
<SettingsSection
@@ -12,11 +12,12 @@ import {
} from '@/components/sections/shared/SettingsSection';
import { isDesktopShell, requestFileAccess } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence';
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
import { recordDeferredOpenCodeRestart } from '@/lib/opencode/deferredRestart';
import { useUIStore } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { isWindowsArm64 } from '@/lib/platform';
import { toast } from '@/components/ui';
export const OpenCodeCliSettings: React.FC = () => {
const { t } = useI18n();
@@ -25,8 +26,6 @@ export const OpenCodeCliSettings: React.FC = () => {
const [isSaving, setIsSaving] = React.useState(false);
const showOpenCodeUpdateNotifications = useUIStore((state) => state.showOpenCodeUpdateNotifications);
const setShowOpenCodeUpdateNotifications = useUIStore((state) => state.setShowOpenCodeUpdateNotifications);
const agentControlToolEnabled = useUIStore((state) => state.agentControlToolEnabled);
const setAgentControlToolEnabled = useUIStore((state) => state.setAgentControlToolEnabled);
React.useEffect(() => {
let cancelled = false;
@@ -89,11 +88,8 @@ export const OpenCodeCliSettings: React.FC = () => {
? trimmed.slice(1, -1).trim()
: trimmed;
await updateDesktopSettings({ opencodeBinary: unquoted });
await reloadOpenCodeConfiguration({
message: t('settings.openchamber.opencodeCli.actions.restartingOpenCode'),
mode: 'projects',
scopes: ['all'],
});
recordDeferredOpenCodeRestart('cli', { id: 'opencode-binary' });
toast.success(t('settings.view.pendingRestart.saved'));
} finally {
setIsSaving(false);
}
@@ -104,11 +100,6 @@ export const OpenCodeCliSettings: React.FC = () => {
void updateDesktopSettings({ showOpenCodeUpdateNotifications: enabled });
}, [setShowOpenCodeUpdateNotifications]);
const handleAgentControlToolChange = React.useCallback((enabled: boolean) => {
setAgentControlToolEnabled(enabled);
void updateDesktopSettings({ agentControlToolEnabled: enabled });
}, [setAgentControlToolEnabled]);
return (
<SettingsSection title={t('settings.openchamber.opencodeCli.title')}>
<div className="space-y-0.5">
@@ -162,15 +153,6 @@ export const OpenCodeCliSettings: React.FC = () => {
/>
)}
<SettingsCheckboxRow
settingsItem="sessions.agent-control-tool"
checked={agentControlToolEnabled}
onChange={handleAgentControlToolChange}
label={t('settings.openchamber.opencodeCli.field.agentControlTool')}
ariaLabel={t('settings.openchamber.opencodeCli.field.agentControlToolAria')}
info={t('settings.openchamber.opencodeCli.field.agentControlToolInfo')}
/>
<div className="flex justify-start py-1.5">
<Button
type="button"
@@ -179,7 +161,7 @@ export const OpenCodeCliSettings: React.FC = () => {
disabled={isLoading || isSaving}
className="shrink-0 !font-normal"
>
{isSaving ? t('settings.common.actions.saving') : t('settings.openchamber.opencodeCli.actions.saveAndReload')}
{isSaving ? t('settings.common.actions.saving') : t('settings.common.actions.saveChanges')}
</Button>
</div>
</SettingsInset>
@@ -219,7 +219,7 @@ export const PasskeySettings: React.FC = () => {
{passkeys.map((passkey) => (
<SettingsFieldRow
key={passkey.id}
label={<span className="truncate">{passkey.label}</span>}
label={<span title={passkey.label}>{passkey.label}</span>}
alignEnd={false}
controlClassName="justify-between sm:flex-1"
>
@@ -87,7 +87,7 @@ export const SessionRetentionSettings: React.FC = () => {
max={MAX_DAYS}
step={1}
aria-label={t('settings.openchamber.sessionRetention.field.retentionPeriodAria')}
className="w-20 tabular-nums"
className="w-24 tabular-nums"
/>
<span className="typography-ui-label text-muted-foreground">{t('settings.openchamber.sessionRetention.field.days')}</span>
<Button
@@ -0,0 +1,65 @@
import { describe, expect, test } from 'bun:test';
import { settleShortcutRecordingState, updateShortcutRecordingState } from './ShortcutRecordingDialog';
const emptyState = { chords: [], livePreview: null, settled: false };
function keyEvent(key: string, modifiers: Partial<Record<'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey', boolean>> = {}) {
const code = /^[a-z]$/i.test(key) ? `Key${key.toUpperCase()}` : /^[0-9]$/.test(key) ? `Digit${key}` : key;
return { key, code, repeat: false, isComposing: false, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, ...modifiers };
}
describe('ShortcutRecordingDialog recording state', () => {
test('previews modifiers and clears the preview when they are released', () => {
const pressed = updateShortcutRecordingState(emptyState, keyEvent('Control', { ctrlKey: true, shiftKey: true }), 'keydown');
expect(pressed.livePreview).toBe('mod+shift');
expect(updateShortcutRecordingState(pressed, keyEvent('Control'), 'keyup').livePreview).toBeNull();
});
test('waits after the first chord and settles when a second chord is recorded', () => {
const first = updateShortcutRecordingState(emptyState, keyEvent('s', { ctrlKey: true }), 'keydown');
const second = updateShortcutRecordingState(first, keyEvent('p'), 'keydown');
const third = updateShortcutRecordingState(second, keyEvent('x'), 'keydown');
expect(first.chords).toEqual(['mod+s']);
expect(first.settled).toBe(false);
expect(second.chords).toEqual(['mod+s', 'p']);
expect(second.settled).toBe(true);
expect(third.chords).toEqual(['x']);
expect(third.settled).toBe(false);
});
test('settles a single chord for timeout and Confirm validation', () => {
const waiting = updateShortcutRecordingState(emptyState, keyEvent('s', { ctrlKey: true }), 'keydown');
expect(settleShortcutRecordingState(waiting)).toEqual({ chords: ['mod+s'], livePreview: null, settled: true });
});
test('records at most three simultaneous keys', () => {
const previous = { chords: ['mod+k'], livePreview: null, settled: false };
const threeKeys = updateShortcutRecordingState(
previous,
keyEvent('s', { ctrlKey: true, shiftKey: true }),
'keydown',
);
const fourKeys = updateShortcutRecordingState(
previous,
keyEvent('s', { ctrlKey: true, metaKey: true, shiftKey: true }),
'keydown',
);
expect(threeKeys.chords).toEqual(['mod+k', 'mod+shift+s']);
expect(fourKeys.chords).toEqual(['mod+k']);
});
test('ignores repeat and IME events', () => {
expect(updateShortcutRecordingState(emptyState, { ...keyEvent('k', { ctrlKey: true }), repeat: true }, 'keydown')).toEqual(emptyState);
expect(updateShortcutRecordingState(emptyState, { ...keyEvent('k', { ctrlKey: true }), isComposing: true }, 'keydown')).toEqual(emptyState);
});
test('records Enter and Escape while Backspace removes the final chord', () => {
const state = { chords: ['mod+k', 'mod+p'], livePreview: null, settled: true };
expect(updateShortcutRecordingState(emptyState, keyEvent('Enter'), 'keydown').chords).toEqual(['enter']);
expect(updateShortcutRecordingState(emptyState, keyEvent('Escape'), 'keydown').chords).toEqual(['escape']);
expect(updateShortcutRecordingState(state, keyEvent('Backspace'), 'keydown').chords).toEqual(['mod+k']);
expect(updateShortcutRecordingState(state, keyEvent('Backspace'), 'keydown').settled).toBe(false);
expect(updateShortcutRecordingState({ chords: ['mod+k'], livePreview: null, settled: false }, keyEvent('Backspace'), 'keydown')).toEqual(emptyState);
});
});
@@ -0,0 +1,315 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
formatShortcutForDisplay,
getShortcutBindingConflicts,
isRiskyBrowserShortcut,
keyToShortcutToken,
resolveShortcutEventKey,
normalizeCombo,
type ShortcutActionId,
type ShortcutBindingConflict,
type ShortcutCombo,
type CustomizableShortcutAction,
} from '@/lib/shortcuts';
import { useI18n } from '@/lib/i18n';
const MODIFIER_KEYS = new Set(['shift', 'control', 'alt', 'meta']);
const MAX_SHORTCUT_KEY_COUNT = 3;
const SECOND_CHORD_TIMEOUT_MS = 3000;
interface RecordingKeyboardEvent {
altKey: boolean;
code: string;
ctrlKey: boolean;
isComposing: boolean;
key: string;
metaKey: boolean;
repeat: boolean;
shiftKey: boolean;
}
interface ShortcutRecordingState {
chords: ShortcutCombo[];
livePreview: ShortcutCombo | null;
settled: boolean;
}
interface ShortcutRecordingDialogProps {
action: CustomizableShortcutAction | null;
overrides: Record<string, string>;
onSave: (
actionId: ShortcutActionId,
combo: ShortcutCombo,
replaceActionId?: ShortcutActionId,
) => void;
onOpenChange: (open: boolean) => void;
}
function getPhysicalKeyCount(
event: Pick<RecordingKeyboardEvent, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>,
includeEventKey = false,
): number {
const keys = new Set<string>();
if (event.altKey) keys.add('alt');
if (event.ctrlKey) keys.add('control');
if (event.metaKey) keys.add('meta');
if (event.shiftKey) keys.add('shift');
if (includeEventKey) keys.add(event.key.toLowerCase());
return keys.size;
}
function isCustomizableConflict(
conflict: ShortcutBindingConflict,
): conflict is ShortcutBindingConflict & { action: CustomizableShortcutAction } {
return conflict.action.customizable;
}
function getModifierPreview(event: RecordingKeyboardEvent): ShortcutCombo | null {
if (getPhysicalKeyCount(event) > MAX_SHORTCUT_KEY_COUNT) return null;
const parts: string[] = [];
if (event.metaKey || event.ctrlKey) parts.push('mod');
if (event.shiftKey) parts.push('shift');
if (event.altKey) parts.push('alt');
return parts.length > 0 ? normalizeCombo(parts.join('+')) : null;
}
function keyboardEventToCombo(event: RecordingKeyboardEvent): ShortcutCombo | null {
if (MODIFIER_KEYS.has(event.key.toLowerCase())) return null;
if (getPhysicalKeyCount(event, true) > MAX_SHORTCUT_KEY_COUNT) return null;
const key = keyToShortcutToken(resolveShortcutEventKey(event));
if (!key) return null;
const parts: string[] = [];
if (event.metaKey || event.ctrlKey) parts.push('mod');
if (event.shiftKey) parts.push('shift');
if (event.altKey) parts.push('alt');
parts.push(key);
return normalizeCombo(parts.join('+'));
}
function modifierKeyUpToCombo(event: React.KeyboardEvent<HTMLDivElement>): ShortcutCombo | null {
const key = event.key.toLowerCase();
if (!MODIFIER_KEYS.has(key)) return null;
if (getPhysicalKeyCount(event, true) > MAX_SHORTCUT_KEY_COUNT) return null;
const parts: string[] = [];
if (event.metaKey || event.ctrlKey || key === 'meta' || key === 'control') parts.push('mod');
if (event.shiftKey || key === 'shift') parts.push('shift');
if (event.altKey || key === 'alt') parts.push('alt');
return parts.length > 0 ? normalizeCombo(parts.join('+')) : null;
}
// eslint-disable-next-line react-refresh/only-export-components -- tested pure recording state transition
export function settleShortcutRecordingState(state: ShortcutRecordingState): ShortcutRecordingState {
return state.chords.length > 0 ? { ...state, livePreview: null, settled: true } : state;
}
// eslint-disable-next-line react-refresh/only-export-components -- tested pure recording state transition
export function updateShortcutRecordingState(
state: ShortcutRecordingState,
event: RecordingKeyboardEvent,
phase: 'keydown' | 'keyup',
): ShortcutRecordingState {
if (event.repeat || event.isComposing) return state;
if (phase === 'keyup') {
return { ...state, livePreview: getModifierPreview(event) };
}
if (event.key === 'Backspace') {
return { chords: state.chords.slice(0, -1), livePreview: null, settled: false };
}
const chord = keyboardEventToCombo(event);
if (chord) {
if (state.settled) {
return { chords: [chord], livePreview: null, settled: false };
}
const chords = state.chords.length < 2 ? [...state.chords, chord] : state.chords;
return {
chords,
livePreview: null,
settled: chords.length === 2,
};
}
return { ...state, livePreview: getModifierPreview(event) };
}
export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = ({
action,
overrides,
onSave,
onOpenChange,
}) => {
const { t } = useI18n();
const actionLabel = (shortcut: CustomizableShortcutAction) => t(shortcut.settingsLabelKey);
const conflictActionLabel = (conflict: ShortcutBindingConflict) => (
conflict.action.customizable
? actionLabel(conflict.action)
: formatShortcutForDisplay(conflict.action.defaultBinding)
);
const [recording, setRecording] = React.useState<ShortcutRecordingState>({ chords: [], livePreview: null, settled: false });
const recordingRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!action) return;
setRecording({ chords: [], livePreview: null, settled: false });
recordingRef.current?.focus();
}, [action]);
const waitingForSecondChord = recording.chords.length === 1 && !recording.settled;
React.useEffect(() => {
if (!waitingForSecondChord) return;
const timeout = window.setTimeout(
() => setRecording(settleShortcutRecordingState),
SECOND_CHORD_TIMEOUT_MS,
);
return () => window.clearTimeout(timeout);
}, [waitingForSecondChord]);
const combo = normalizeCombo(recording.chords.join(' '));
const conflicts = React.useMemo(
() => action && combo ? getShortcutBindingConflicts(action.id, combo, overrides) : [],
[action, combo, overrides],
);
const protectedConflict = conflicts.find((conflict) => (
!conflict.action.customizable && conflict.kind !== 'contextual-prefix'
));
const customizableConflicts = conflicts.filter(isCustomizableConflict);
const prefixConflict = customizableConflicts.find((conflict) => conflict.kind === 'prefix');
const exactConflict = customizableConflicts.find((conflict) => conflict.kind === 'exact');
const contextualPrefixConflict = conflicts.find((conflict) => conflict.kind === 'contextual-prefix');
const close = () => onOpenChange(false);
const confirm = () => {
if (!recording.settled) setRecording(settleShortcutRecordingState);
if (!action || !combo || protectedConflict || prefixConflict) return;
onSave(action.id, combo, exactConflict?.action.id);
close();
};
const handleRecordingEvent = (event: React.KeyboardEvent<HTMLDivElement>, phase: 'keydown' | 'keyup') => {
event.preventDefault();
event.stopPropagation();
const isPrefixStyleAction = Boolean(action && 'prefixStyle' in action && action.prefixStyle);
if (phase === 'keyup' && isPrefixStyleAction && recording.chords.length === 0) {
const modifierCombo = modifierKeyUpToCombo(event);
if (modifierCombo) {
setRecording({ chords: [modifierCombo], livePreview: null, settled: true });
return;
}
}
const nextRecording = updateShortcutRecordingState(recording, {
altKey: event.altKey,
code: event.nativeEvent.code,
ctrlKey: event.ctrlKey,
isComposing: event.nativeEvent.isComposing,
key: event.key,
metaKey: event.metaKey,
repeat: event.repeat,
shiftKey: event.shiftKey,
}, phase);
setRecording(isPrefixStyleAction && nextRecording.chords.length > 1
? recording
: nextRecording);
};
return (
<Dialog
open={action !== null}
onOpenChange={(open, eventDetails) => {
if (!open) {
eventDetails.cancel();
}
}}
>
<DialogContent className="max-w-md" initialFocus={recordingRef} showCloseButton={false}>
<DialogHeader>
<DialogTitle>
{action ? t('settings.openchamber.keyboardShortcuts.dialog.title', { action: actionLabel(action) }) : ''}
</DialogTitle>
<DialogDescription>{t('settings.openchamber.keyboardShortcuts.dialog.instructions')}</DialogDescription>
</DialogHeader>
<div
className="flex min-h-28 items-center justify-center rounded-lg border border-border bg-[var(--surface-elevated)] px-4 py-5 text-center outline-none focus-visible:ring-2 focus-visible:ring-ring"
tabIndex={0}
ref={recordingRef}
onKeyDown={(event) => handleRecordingEvent(event, 'keydown')}
onKeyUp={(event) => handleRecordingEvent(event, 'keyup')}
onBlur={() => setRecording((current) => ({ ...current, livePreview: null }))}
>
<div className="flex flex-wrap items-center justify-center gap-2">
{recording.chords.map((chord, index) => (
<kbd key={`${chord}-${index}`} className="rounded-md border border-border bg-muted px-3 py-2 typography-ui-label font-mono text-foreground">
{formatShortcutForDisplay(chord)}
</kbd>
))}
{recording.livePreview ? (
<kbd className="rounded-md border border-dashed border-border bg-muted px-3 py-2 typography-ui-label font-mono text-muted-foreground">
{formatShortcutForDisplay(recording.livePreview)}
</kbd>
) : null}
{recording.chords.length === 0 && !recording.livePreview ? (
<span className="typography-ui-label text-muted-foreground">
{t('settings.openchamber.keyboardShortcuts.dialog.recording')}
</span>
) : null}
</div>
</div>
{recording.settled && protectedConflict ? (
<p className="typography-meta text-[var(--status-error)]">
{t('settings.openchamber.keyboardShortcuts.error.internalConflict')}
</p>
) : recording.settled && prefixConflict ? (
<p className="typography-meta text-[var(--status-error)]">
{t('settings.openchamber.keyboardShortcuts.error.prefixConflict', { action: actionLabel(prefixConflict.action) })}
</p>
) : null}
{recording.settled && exactConflict && !protectedConflict && !prefixConflict ? (
<p className="typography-meta text-[var(--status-warning)]">
{t('settings.openchamber.keyboardShortcuts.error.exactConflict', { action: actionLabel(exactConflict.action) })}
</p>
) : null}
{recording.settled && contextualPrefixConflict && !protectedConflict && !prefixConflict ? (
<p className="typography-meta text-[var(--status-warning)]">
{t('settings.openchamber.keyboardShortcuts.warning.contextualPrefix', {
action: conflictActionLabel(contextualPrefixConflict),
})}
</p>
) : null}
{recording.settled && combo && isRiskyBrowserShortcut(combo) ? (
<p className="typography-meta text-[var(--status-warning)]">
{t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut')}
</p>
) : null}
<DialogFooter>
<Button type="button" variant="ghost" size="sm" onClick={close}>
{t('settings.common.actions.cancel')}
</Button>
<Button
type="button"
size="sm"
disabled={!combo || (recording.settled && (Boolean(protectedConflict) || Boolean(prefixConflict)))}
onClick={confirm}
>
{t('settings.openchamber.keyboardShortcuts.actions.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -205,7 +205,7 @@ const getFallbackInstallCommand = (provider: string, platform = getClientInstall
if (platform === 'darwin') {
return 'brew install cloudflared';
}
return 'https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflared/downloads/';
return 'https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/';
};
const createTunnelDependencyInstallInfo = (provider: string, checkData?: TunnelCheckResponse): TunnelDependencyInstallInfo => {
@@ -1051,13 +1051,13 @@ export const VoiceSettings: React.FC = () => {
{/* Speech Rate */}
<SettingsFieldRow label={t('settings.voice.page.field.speechRate')}>
{!isMobile && <input type="range" min={0.5} max={2} step={0.1} value={speechRate} onChange={(e) => setSpeechRate(Number(e.target.value))} className={sliderClass} />}
<NumberInput value={speechRate} onValueChange={setSpeechRate} min={0.5} max={2} step={0.1} className="w-16 tabular-nums" />
<NumberInput value={speechRate} onValueChange={setSpeechRate} min={0.5} max={2} step={0.1} className="w-20 tabular-nums" />
</SettingsFieldRow>
{/* Speech Pitch */}
<SettingsFieldRow label={t('settings.voice.page.field.speechPitch')}>
{!isMobile && <input type="range" min={0.5} max={2} step={0.1} value={speechPitch} onChange={(e) => setSpeechPitch(Number(e.target.value))} className={sliderClass} />}
<NumberInput value={speechPitch} onValueChange={setSpeechPitch} min={0.5} max={2} step={0.1} className="w-16 tabular-nums" />
<NumberInput value={speechPitch} onValueChange={setSpeechPitch} min={0.5} max={2} step={0.1} className="w-20 tabular-nums" />
</SettingsFieldRow>
{/* Speech Volume */}
@@ -109,7 +109,11 @@ export const AddPluginDialog: React.FC<AddPluginDialogProps> = ({
});
}
if (result.ok) {
toast.success(result.message || t('settings.plugins.toast.created'));
if (result.restartDeferred) {
toast.success(t('settings.view.pendingRestart.saved'));
} else {
toast.success(result.message || t('settings.plugins.toast.created'));
}
if (result.reloadFailed) {
toast.warning(t('settings.plugins.toast.reloadFailed'));
}
@@ -183,6 +183,8 @@ export const PluginsPage: React.FC = () => {
result.message || t('settings.plugins.toast.reloadFailed'),
{ description: result.warning },
);
} else if (result.restartDeferred) {
toast.success(t('settings.view.pendingRestart.saved'));
} else {
toast.success(result.message || t('settings.plugins.toast.updated'));
}
@@ -299,6 +301,8 @@ export const PluginsPage: React.FC = () => {
result.message || t('settings.plugins.toast.reloadFailed'),
{ description: result.warning },
);
} else if (result.restartDeferred) {
toast.success(t('settings.view.pendingRestart.saved'));
} else {
toast.success(result.message || t('settings.plugins.toast.updated'));
}
@@ -3,6 +3,15 @@ import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/icon/Icon';
import { ModelSelector } from '@/components/sections/agents/ModelSelector';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
SettingsFieldRow,
SETTINGS_CUSTOM_TRIGGER_CLASS,
SETTINGS_SELECT_ROW_TRIGGER_CLASS,
SETTINGS_SELECT_SIZE,
} from '@/components/sections/shared/SettingsSection';
import { useConfigStore } from '@/stores/useConfigStore';
import { modelVariantNames } from '@/lib/modelVariants';
import { PROJECT_COLORS, PROJECT_ICONS, PROJECT_COLOR_MAP as COLOR_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useI18n } from '@/lib/i18n';
@@ -19,9 +28,14 @@ type ProjectIdentityFieldsProps = {
form: ProjectIdentityFormState;
};
const NO_VARIANT_VALUE = '__default__';
const formatVariantLabel = (variant: string): string => variant.charAt(0).toUpperCase() + variant.slice(1);
export const ProjectIdentityFields: React.FC<ProjectIdentityFieldsProps> = ({ form }) => {
const { t } = useI18n();
const { currentTheme } = useThemeSystem();
const providers = useConfigStore((state) => state.providers);
const {
name,
setName,
@@ -32,7 +46,9 @@ export const ProjectIdentityFields: React.FC<ProjectIdentityFieldsProps> = ({ fo
iconBackground,
setIconBackground,
parsedDefaultModel,
defaultVariant,
handleDefaultModelChange,
handleDefaultVariantChange,
isUploadingIcon,
isRemovingCustomIcon,
isDiscoveringIcon,
@@ -53,6 +69,15 @@ export const ProjectIdentityFields: React.FC<ProjectIdentityFieldsProps> = ({ fo
project,
} = form;
const availableVariants = React.useMemo(() => {
const { providerId, modelId } = parsedDefaultModel;
if (!providerId || !modelId) return [];
const model = providers
.find((provider) => provider.id === providerId)
?.models.find((entry) => entry.id === modelId);
return modelVariantNames(model);
}, [parsedDefaultModel, providers]);
if (!project) {
return null;
}
@@ -75,16 +100,51 @@ export const ProjectIdentityFields: React.FC<ProjectIdentityFieldsProps> = ({ fo
</ProjectSettingsSubsection>
<ProjectSettingsSubsection
title={t('settings.projects.page.field.defaultModel')}
info={t('settings.projects.page.field.defaultModelDescription')}
settingsItem="projects.default-model"
title={t('settings.projects.page.section.chatDefaults')}
info={t('settings.projects.page.section.chatDefaultsDescription')}
contentClassName="space-y-0"
>
<ModelSelector
providerId={parsedDefaultModel.providerId}
modelId={parsedDefaultModel.modelId}
onChange={handleDefaultModelChange}
className={cn('h-8 min-h-8 rounded-md px-3 max-w-48', PROJECT_SETTINGS_CONTROL_WIDTH)}
/>
<SettingsFieldRow
settingsItem="projects.default-model"
label={t('settings.projects.page.field.projectModel')}
>
<ModelSelector
providerId={parsedDefaultModel.providerId}
modelId={parsedDefaultModel.modelId}
onChange={handleDefaultModelChange}
className={SETTINGS_CUSTOM_TRIGGER_CLASS}
/>
</SettingsFieldRow>
{availableVariants.length > 0 ? (
<SettingsFieldRow
settingsItem="projects.default-thinking"
label={t('settings.projects.page.field.projectThinking')}
>
<Select
value={defaultVariant ?? NO_VARIANT_VALUE}
onValueChange={(value) => handleDefaultVariantChange(value === NO_VARIANT_VALUE ? undefined : value)}
>
<SelectTrigger
size={SETTINGS_SELECT_SIZE}
className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}
aria-label={t('settings.projects.page.field.projectThinking')}
>
<SelectValue>
{defaultVariant
? formatVariantLabel(defaultVariant)
: t('settings.projects.page.option.thinkingDefault')}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_VARIANT_VALUE}>{t('settings.projects.page.option.thinkingDefault')}</SelectItem>
{availableVariants.map((variant) => (
<SelectItem key={variant} value={variant}>{formatVariantLabel(variant)}</SelectItem>
))}
</SelectContent>
</Select>
</SettingsFieldRow>
) : null}
</ProjectSettingsSubsection>
<ProjectSettingsSubsection
@@ -37,6 +37,7 @@ export const ProjectsPage: React.FC = () => {
color: data.color,
iconBackground: data.iconBackground,
defaultModel: data.defaultModel ?? null,
defaultVariant: data.defaultVariant ?? null,
});
}, [selectedProject, updateProjectMeta]);
@@ -7,7 +7,7 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
const HEX_COLOR_PATTERN = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/;
export const normalizeProjectIconBackground = (value: string | null | undefined): string | null => {
const normalizeProjectIconBackground = (value: string | null | undefined): string | null => {
if (!value) {
return null;
}
@@ -24,11 +24,12 @@ export type ProjectIdentitySaveData = {
color: string | null;
iconBackground: string | null;
defaultModel: string | null;
defaultVariant: string | null;
};
type EditableProject = Pick<
ProjectEntry,
'id' | 'label' | 'icon' | 'color' | 'iconBackground' | 'defaultModel' | 'iconImage' | 'path'
'id' | 'label' | 'icon' | 'color' | 'iconBackground' | 'defaultModel' | 'defaultVariant' | 'iconImage' | 'path'
>;
export const useProjectIdentityForm = (project: EditableProject | null) => {
@@ -45,6 +46,7 @@ export const useProjectIdentityForm = (project: EditableProject | null) => {
const [color, setColor] = React.useState<string | null>(null);
const [iconBackground, setIconBackground] = React.useState<string | null>(null);
const [defaultModel, setDefaultModel] = React.useState<string | undefined>(undefined);
const [defaultVariant, setDefaultVariant] = React.useState<string | undefined>(undefined);
const [isUploadingIcon, setIsUploadingIcon] = React.useState(false);
const [isRemovingCustomIcon, setIsRemovingCustomIcon] = React.useState(false);
const [isDiscoveringIcon, setIsDiscoveringIcon] = React.useState(false);
@@ -73,6 +75,7 @@ export const useProjectIdentityForm = (project: EditableProject | null) => {
setColor(null);
setIconBackground(null);
setDefaultModel(undefined);
setDefaultVariant(undefined);
return;
}
setName(project.label ?? '');
@@ -80,6 +83,7 @@ export const useProjectIdentityForm = (project: EditableProject | null) => {
setColor(project.color ?? null);
setIconBackground(project.iconBackground ?? null);
setDefaultModel(project.defaultModel);
setDefaultVariant(project.defaultVariant);
setPendingRemoveImageIcon(false);
clearPendingUploadIcon();
setPreviewImageFailed(false);
@@ -110,12 +114,20 @@ export const useProjectIdentityForm = (project: EditableProject | null) => {
|| color !== (project?.color ?? null)
|| iconBackground !== (project?.iconBackground ?? null)
|| (defaultModel ?? undefined) !== (project?.defaultModel ?? undefined)
|| (defaultVariant ?? undefined) !== (project?.defaultVariant ?? undefined)
|| pendingRemoveImageIcon
|| Boolean(pendingUploadIconFile)
);
const handleDefaultModelChange = React.useCallback((providerId: string, modelId: string) => {
setDefaultModel(providerId && modelId ? `${providerId}/${modelId}` : undefined);
// Variants belong to a model. Carrying the old one over would pin a name
// the new model may not have.
setDefaultVariant(undefined);
}, []);
const handleDefaultVariantChange = React.useCallback((variant: string | undefined) => {
setDefaultVariant(variant);
}, []);
const handleUploadIcon = React.useCallback((file: File | null) => {
@@ -232,11 +244,13 @@ export const useProjectIdentityForm = (project: EditableProject | null) => {
color,
iconBackground: normalizeProjectIconBackground(willRemoveImageIcon ? null : iconBackground),
defaultModel: defaultModel ?? null,
defaultVariant: defaultModel ? defaultVariant ?? null : null,
};
}, [
clearPendingUploadIcon,
color,
defaultModel,
defaultVariant,
icon,
iconBackground,
name,
@@ -262,8 +276,10 @@ export const useProjectIdentityForm = (project: EditableProject | null) => {
iconBackground,
setIconBackground,
defaultModel,
defaultVariant,
parsedDefaultModel,
handleDefaultModelChange,
handleDefaultVariantChange,
isUploadingIcon,
isRemovingCustomIcon,
isDiscoveringIcon,
@@ -10,9 +10,11 @@ import {
} from '@/components/sections/shared/SettingsSection';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import {
CUSTOM_PROVIDER_PROTOCOLS,
createEmptyCustomProviderForm,
createHeaderRow,
createModelRow,
@@ -161,6 +163,31 @@ export const CustomProviderForm: React.FC<CustomProviderFormProps> = ({
{err.providerID ? <p className="mt-1 typography-meta text-[var(--status-error)]">{err.providerID}</p> : null}
</SettingsStackedField>
<SettingsStackedField
label={t('settings.providers.page.custom.field.protocol.label')}
info={t('settings.providers.page.custom.field.protocol.info')}
>
<Select
value={form.protocol}
onValueChange={(protocol) => {
if (!(protocol in CUSTOM_PROVIDER_PROTOCOLS)) {
return;
}
setForm((prev) => ({ ...prev, protocol }));
}}
disabled={busy}
>
<SelectTrigger aria-label={t('settings.providers.page.custom.field.protocol.label')} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="openai-chat">{t('settings.providers.page.custom.field.protocol.openaiChat')}</SelectItem>
<SelectItem value="openai-responses">{t('settings.providers.page.custom.field.protocol.openaiResponses')}</SelectItem>
<SelectItem value="anthropic-messages">{t('settings.providers.page.custom.field.protocol.anthropicMessages')}</SelectItem>
</SelectContent>
</Select>
</SettingsStackedField>
<SettingsStackedField
label={t('settings.providers.page.custom.field.name.label')}
info={t('settings.providers.page.custom.field.name.info')}
@@ -0,0 +1,459 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { toast } from '@/components/ui';
import { Icon } from '@/components/icon/Icon';
import {
SETTINGS_SELECT_ROW_TRIGGER_CLASS,
SETTINGS_SELECT_SIZE,
} from '@/components/sections/shared/SettingsSection';
import { useI18n, type I18nKey } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { copyTextToClipboard } from '@/lib/clipboard';
import { openExternalUrl } from '@/lib/url';
import { opencodeClient } from '@/lib/opencode/client';
import {
collectPromptInputs,
defaultPromptValues,
describeOAuthError,
firstUnansweredPrompt,
parseAuthPrompts,
parseAuthorization,
shouldOpenAuthorizationUrl,
visiblePrompts,
type AuthPrompt,
type OAuthAuthorization,
} from './provider-oauth';
export interface ProviderOAuthMethod {
/** Index into the provider's full auth-method list, which is what OpenCode's `method` parameter addresses. */
index: number;
label: string;
prompts?: unknown;
}
interface ProviderOAuthMethodsProps {
providerId: string;
methods: ProviderOAuthMethod[];
/** Called once a credential has been stored, so the caller can reload providers. */
onConnected: () => void | Promise<void>;
/** Layout only — the caller owns separation from whatever sits above. */
className?: string;
}
type Flow =
| { phase: 'idle' }
| { phase: 'prompting'; methodIndex: number; prompts: AuthPrompt[]; error: string | null }
| { phase: 'authorizing'; methodIndex: number }
/** `auto`: the callback request is in flight and blocks until the browser sign-in finishes. */
| { phase: 'waiting'; methodIndex: number; authorization: OAuthAuthorization }
/** `code`: waiting for the user to paste a code out of the browser. */
| { phase: 'awaitingCode'; methodIndex: number; authorization: OAuthAuthorization; submitting: boolean }
| { phase: 'failed'; methodIndex: number; message: string };
const IDLE: Flow = { phase: 'idle' };
/**
* OAuth sign-in for a provider's auth methods.
*
* The completion method reported by `authorize` drives everything: `auto`
* chains straight into `callback` and holds it open until the user finishes in
* the browser, `code` collects a pasted code first. See `provider-oauth.ts`.
*
* Only one method can run at a time, and the in-flight callback is aborted when
* this component unmounts. Mount it with `key={providerId}` so switching
* providers starts from a clean flow.
*/
export const ProviderOAuthMethods: React.FC<ProviderOAuthMethodsProps> = ({
providerId,
methods,
onConnected,
className,
}) => {
const { t } = useI18n();
const [flow, setFlow] = React.useState<Flow>(IDLE);
const [promptValues, setPromptValues] = React.useState<Record<string, string>>({});
const [codeInput, setCodeInput] = React.useState('');
const callbackAbortRef = React.useRef<AbortController | null>(null);
React.useEffect(() => () => callbackAbortRef.current?.abort(), []);
const activeIndex = flow.phase === 'idle' ? null : flow.methodIndex;
const busy = flow.phase === 'authorizing'
|| flow.phase === 'waiting'
|| (flow.phase === 'awaitingCode' && flow.submitting);
const copy = async (value: string, successKey: I18nKey, failureKey: I18nKey) => {
const result = await copyTextToClipboard(value);
if (result.ok) {
toast.success(t(successKey));
return;
}
console.error('Failed to copy OAuth value:', result.error);
toast.error(t(failureKey));
};
/**
* Runs the blocking half of the flow. Never throws: the caller has already
* handed control to the user, so a failure here is a flow state, not an
* exception to unwind.
*/
const runCallback = async (methodIndex: number, code?: string) => {
const controller = new AbortController();
callbackAbortRef.current?.abort();
callbackAbortRef.current = controller;
try {
const result = await opencodeClient.getSdkClient().provider.oauth.callback(
{
providerID: providerId,
method: methodIndex,
...(code ? { code } : {}),
},
{ signal: controller.signal },
);
if (controller.signal.aborted) {
return;
}
if (result.error) {
throw result.error;
}
setFlow(IDLE);
toast.success(t('settings.providers.page.toast.oauthCompleted'));
await onConnected();
} catch (error) {
if (controller.signal.aborted) {
return;
}
console.error('Failed to complete OAuth flow:', error);
setFlow({
phase: 'failed',
methodIndex,
message: describeOAuthError(error, t, 'settings.providers.page.toast.oauthCompleteFailed'),
});
} finally {
if (callbackAbortRef.current === controller) {
callbackAbortRef.current = null;
}
}
};
const runAuthorize = async (methodIndex: number, inputs: Record<string, string>) => {
setFlow({ phase: 'authorizing', methodIndex });
let authorization: OAuthAuthorization;
try {
const result = await opencodeClient.getSdkClient().provider.oauth.authorize({
providerID: providerId,
method: methodIndex,
...(Object.keys(inputs).length > 0 ? { inputs } : {}),
});
if (result.error) {
throw result.error;
}
const parsed = parseAuthorization(result.data);
if (!parsed) {
setFlow({
phase: 'failed',
methodIndex,
message: t('settings.providers.page.toast.oauthDetailsMissing'),
});
return;
}
authorization = parsed;
} catch (error) {
console.error('Failed to start OAuth flow:', error);
setFlow({
phase: 'failed',
methodIndex,
message: describeOAuthError(error, t, 'settings.providers.page.toast.oauthStartFailed'),
});
return;
}
// Claude Code CLI owns its OAuth flow and opens the browser itself. Its
// plugin URL is informational only; opening it creates a misleading docs
// tab alongside the real sign-in page.
if (authorization.url && shouldOpenAuthorizationUrl(providerId, authorization.url)) {
void openExternalUrl(authorization.url);
}
if (authorization.method === 'code') {
setCodeInput('');
setFlow({ phase: 'awaitingCode', methodIndex, authorization, submitting: false });
return;
}
setFlow({ phase: 'waiting', methodIndex, authorization });
await runCallback(methodIndex);
};
const beginConnect = (method: ProviderOAuthMethod) => {
const prompts = parseAuthPrompts(method.prompts);
if (prompts.length === 0) {
void runAuthorize(method.index, {});
return;
}
setPromptValues(defaultPromptValues(prompts));
setFlow({ phase: 'prompting', methodIndex: method.index, prompts, error: null });
};
const submitPrompts = () => {
if (flow.phase !== 'prompting') {
return;
}
const unanswered = firstUnansweredPrompt(flow.prompts, promptValues);
if (unanswered) {
setFlow({
...flow,
error: t('settings.providers.page.auth.oauth.promptRequired', { field: unanswered.message }),
});
return;
}
void runAuthorize(flow.methodIndex, collectPromptInputs(flow.prompts, promptValues));
};
const submitCode = () => {
if (flow.phase !== 'awaitingCode') {
return;
}
const code = codeInput.trim();
if (!code) {
return;
}
setFlow({ ...flow, submitting: true });
void runCallback(flow.methodIndex, code);
};
/**
* Stops tracking the attempt. Upstream keeps its pending authorization until
* a new `authorize` replaces it, so reconnecting is always safe.
*/
const cancel = () => {
callbackAbortRef.current?.abort();
callbackAbortRef.current = null;
setFlow(IDLE);
};
const renderPrompt = (prompt: AuthPrompt) => {
const value = promptValues[prompt.key] ?? '';
const setValue = (next: string) =>
setPromptValues((prev) => ({ ...prev, [prompt.key]: next }));
return (
<div key={prompt.key} className="space-y-1.5">
<label className="typography-ui-label text-foreground">{prompt.message}</label>
{prompt.type === 'select' ? (
<Select value={value} onValueChange={setValue}>
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}>
<SelectValue>
{(current) => prompt.options.find((option) => option.value === current)?.label ?? null}
</SelectValue>
</SelectTrigger>
<SelectContent>
{prompt.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.hint ? `${option.label} · ${option.hint}` : option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder={prompt.placeholder}
className="max-w-[24rem] text-xs"
/>
)}
</div>
);
};
const renderAuthorizationDetails = (authorization: OAuthAuthorization) => (
<>
{authorization.instructions && (
<p className="typography-meta text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-2 py-1.5 rounded">
{authorization.instructions}
</p>
)}
{authorization.userCode && (
<div className="flex items-center gap-2">
<Input
value={authorization.userCode}
readOnly
aria-label={t('settings.providers.page.auth.oauth.deviceCodeLabel')}
className="font-mono text-center tracking-widest"
/>
<Button
variant="outline"
size="xs"
className="!font-normal shrink-0"
onClick={() => void copy(
authorization.userCode ?? '',
'settings.providers.page.toast.deviceCodeCopied',
'settings.providers.page.toast.deviceCodeCopyFailed',
)}
>
{t('settings.providers.page.actions.copyCode')}
</Button>
</div>
)}
{authorization.url && (
<div className="flex items-center gap-2">
<Input
value={authorization.url}
readOnly
aria-label={t('settings.providers.page.auth.oauth.linkLabel')}
className="text-xs text-muted-foreground"
/>
<div className="flex gap-1 shrink-0">
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => void openExternalUrl(authorization.url ?? '')}
>
{t('settings.providers.page.actions.open')}
</Button>
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => void copy(
authorization.url ?? '',
'settings.providers.page.toast.oauthLinkCopied',
'settings.providers.page.toast.oauthLinkCopyFailed',
)}
>
{t('settings.providers.page.actions.copy')}
</Button>
</div>
</div>
)}
</>
);
return (
<div className={cn('space-y-4', className)}>
{methods.map((method) => {
const isActive = activeIndex === method.index;
return (
<div key={method.index} className="space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="typography-ui-label text-foreground">{method.label}</div>
<Button
variant="outline"
size="xs"
className="!font-normal shrink-0"
onClick={() => beginConnect(method)}
disabled={busy}
>
{t('settings.providers.page.actions.connect')}
</Button>
</div>
{isActive && flow.phase === 'prompting' && (
<div className="space-y-3">
{visiblePrompts(flow.prompts, promptValues).map(renderPrompt)}
{flow.error && (
<p className="typography-meta text-[var(--status-error)]">{flow.error}</p>
)}
<div className="flex items-center gap-2">
<Button size="xs" className="!font-normal" onClick={submitPrompts}>
{t('settings.providers.page.actions.continue')}
</Button>
<Button variant="ghost" size="xs" className="!font-normal" onClick={cancel}>
{t('settings.providers.page.actions.cancel')}
</Button>
</div>
</div>
)}
{isActive && flow.phase === 'authorizing' && (
<p className="typography-meta text-muted-foreground flex items-center gap-2">
<Icon name="loader" className="h-3.5 w-3.5 animate-spin" />
{t('settings.providers.page.auth.oauth.starting')}
</p>
)}
{isActive && flow.phase === 'waiting' && (
<div className="space-y-3">
{renderAuthorizationDetails(flow.authorization)}
<div className="flex items-center justify-between gap-2">
<p className="typography-meta text-muted-foreground flex items-center gap-2">
<Icon name="loader" className="h-3.5 w-3.5 animate-spin" />
{t('settings.providers.page.auth.oauth.waiting')}
</p>
<Button variant="ghost" size="xs" className="!font-normal shrink-0" onClick={cancel}>
{t('settings.providers.page.actions.cancel')}
</Button>
</div>
<p className="typography-meta text-muted-foreground">
{t('settings.providers.page.auth.oauth.waitingHint')}
</p>
</div>
)}
{isActive && flow.phase === 'awaitingCode' && (
<div className="space-y-3">
{renderAuthorizationDetails(flow.authorization)}
<p className="typography-meta text-muted-foreground">
{t('settings.providers.page.auth.oauth.codeHint')}
</p>
<div className="flex items-center gap-2">
<Input
value={codeInput}
onChange={(event) => setCodeInput(event.target.value)}
placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')}
className="font-mono text-xs"
disabled={flow.submitting}
/>
<Button
size="xs"
className="!font-normal shrink-0"
onClick={submitCode}
disabled={flow.submitting || codeInput.trim().length === 0}
>
{flow.submitting
? t('settings.providers.page.actions.saving')
: t('settings.providers.page.actions.complete')}
</Button>
<Button
variant="ghost"
size="xs"
className="!font-normal shrink-0"
onClick={cancel}
disabled={flow.submitting}
>
{t('settings.providers.page.actions.cancel')}
</Button>
</div>
</div>
)}
{isActive && flow.phase === 'failed' && (
<div className="space-y-2">
<p className="typography-meta text-[var(--status-error)]">{flow.message}</p>
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => beginConnect(method)}
>
{t('settings.providers.page.actions.tryAgain')}
</Button>
</div>
)}
</div>
);
})}
</div>
);
};
@@ -1,5 +1,12 @@
import { describe, expect, test } from 'bun:test';
import { shouldLoadAvailableProviders } from './providerAvailability';
import { requiresProviderAuth, shouldLoadAvailableProviders } from './providerAvailability';
import {
getOAuthAuthMethods,
normalizeAuthType,
parseAuthPayload,
requiresOpenCodeRestartAfterOAuth,
shouldShowApiKeyAuth,
} from './providerAuth';
describe('ProvidersPage available provider loading', () => {
test('loads available providers only in add-provider mode', () => {
@@ -7,3 +14,61 @@ describe('ProvidersPage available provider loading', () => {
expect(shouldLoadAvailableProviders(true)).toBe(true);
});
});
describe('ProvidersPage provider authentication', () => {
test('does not require credentials for a custom provider defined in config', () => {
expect(requiresProviderAuth(true, false, true)).toBe(false);
expect(requiresProviderAuth(true, false, false)).toBe(true);
expect(requiresProviderAuth(true, true, false)).toBe(false);
});
});
describe('provider auth method helpers', () => {
test('normalizeAuthType recognizes oauth and api labels', () => {
expect(normalizeAuthType({ type: 'oauth', label: 'Login with Cursor' })).toBe('oauth');
expect(normalizeAuthType({ type: 'api', label: 'API Key' })).toBe('api');
expect(normalizeAuthType({ label: 'OAuth browser login' })).toBe('oauth');
expect(normalizeAuthType({ name: 'API key' })).toBe('api');
});
test('parseAuthPayload keeps only object auth method entries', () => {
expect(parseAuthPayload({
cursor: [{ type: 'oauth', label: 'Cursor' }, 'skip'],
openai: null,
})).toEqual({
cursor: [{ type: 'oauth', label: 'Cursor' }],
});
expect(parseAuthPayload(null)).toEqual({});
});
test('shouldShowApiKeyAuth hides API key for oauth-only providers', () => {
expect(shouldShowApiKeyAuth([{ type: 'oauth', label: 'Cursor OAuth' }])).toBe(false);
expect(shouldShowApiKeyAuth([
{ type: 'api', label: 'API Key' },
{ type: 'oauth', label: 'ChatGPT' },
])).toBe(true);
expect(shouldShowApiKeyAuth([{ type: 'api', label: 'API Key' }])).toBe(true);
// Unknown / unloaded methods keep the legacy API key fallback.
expect(shouldShowApiKeyAuth([])).toBe(true);
});
test('getOAuthAuthMethods preserves original method indexes', () => {
const methods = [
{ type: 'api', label: 'API Key' },
{ type: 'oauth', label: 'OAuth' },
{ type: 'oauth', label: 'Device' },
];
expect(getOAuthAuthMethods(methods)).toEqual([
{ method: methods[1], methodIndex: 1 },
{ method: methods[2], methodIndex: 2 },
]);
expect(getOAuthAuthMethods([{ type: 'oauth', label: 'Cursor' }])).toEqual([
{ method: { type: 'oauth', label: 'Cursor' }, methodIndex: 0 },
]);
});
test('Claude CLI OAuth does not require an OpenCode restart', () => {
expect(requiresOpenCodeRestartAfterOAuth('claude-code')).toBe(false);
expect(requiresOpenCodeRestartAfterOAuth('github-copilot')).toBe(true);
});
});
@@ -1,10 +1,12 @@
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import { SettingsSection, SETTINGS_CUSTOM_TRIGGER_CLASS } from '@/components/sections/shared/SettingsSection';
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { useConfigStore } from '@/stores/useConfigStore';
import { selectProvidersForDirectory, useConfigStore } from '@/stores/useConfigStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { useUIStore } from '@/stores/useUIStore';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -17,16 +19,23 @@ import {
import { toast } from '@/components/ui';
import { Icon } from "@/components/icon/Icon";
import type { IconName } from "@/components/icon/icons";
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
import { noteDeferredRestartFromPayload, recordDeferredOpenCodeRestart } from '@/lib/opencode/deferredRestart';
import { cn } from '@/lib/utils';
import { copyTextToClipboard } from '@/lib/clipboard';
import { openExternalUrl } from '@/lib/url';
import type { ModelMetadata } from '@/types';
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { opencodeClient } from '@/lib/opencode/client';
import { shouldLoadAvailableProviders } from './providerAvailability';
import { requiresProviderAuth, shouldLoadAvailableProviders } from './providerAvailability';
import {
getOAuthAuthMethods,
parseAuthPayload,
requiresOpenCodeRestartAfterOAuth,
shouldShowApiKeyAuth,
type AuthMethod,
type OAuthAuthMethodEntry,
} from './providerAuth';
import { CustomProviderForm } from './CustomProviderForm';
import { ProviderOAuthMethods, type ProviderOAuthMethod } from './ProviderOAuthMethods';
import {
buildAuthSetRequest,
buildProviderUpsertRequest,
@@ -59,16 +68,6 @@ const formatTokens = (value?: number | null) => {
const ADD_PROVIDER_ID = '__add_provider__';
interface AuthMethod {
type?: string;
name?: string;
label?: string;
description?: string;
help?: string;
method?: number;
[key: string]: unknown;
}
interface ProviderOption {
id: string;
name?: string;
@@ -89,27 +88,15 @@ interface ProviderSources {
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
const normalizeAuthType = (method: AuthMethod) => {
const raw = typeof method.type === 'string' ? method.type : '';
const label = `${method.name ?? ''} ${method.label ?? ''}`.toLowerCase();
const merged = `${raw} ${label}`.toLowerCase();
if (merged.includes('oauth')) return 'oauth';
if (merged.includes('api')) return 'api';
return raw.toLowerCase();
};
const parseAuthPayload = (payload: unknown): Record<string, AuthMethod[]> => {
if (!isRecord(payload)) {
return {};
}
const result: Record<string, AuthMethod[]> = {};
for (const [providerId, value] of Object.entries(payload)) {
if (Array.isArray(value)) {
result[providerId] = value.filter((entry) => isRecord(entry)) as AuthMethod[];
}
}
return result;
};
const toOAuthMethods = (
entries: OAuthAuthMethodEntry[],
fallbackLabel: (index: number) => string,
): ProviderOAuthMethod[] =>
entries.map(({ method, methodIndex }) => ({
index: methodIndex,
label: method.label || method.name || fallbackLabel(methodIndex),
prompts: method.prompts,
}));
const normalizeProviderEntry = (entry: unknown): ProviderOption | null => {
if (typeof entry === 'string') {
@@ -159,7 +146,10 @@ const parseProvidersPayload = (payload: unknown): ProviderOption[] => {
export const ProvidersPage: React.FC = () => {
const { t } = useI18n();
const providers = useConfigStore((state) => state.providers);
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const providers = useConfigStore((state) => selectProvidersForDirectory(state, settingsDirectory));
const selectedProviderId = useConfigStore((state) => state.selectedProviderId);
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
const getModelMetadata = useConfigStore((state) => state.getModelMetadata);
@@ -173,9 +163,6 @@ export const ProvidersPage: React.FC = () => {
const [apiKeyInputs, setApiKeyInputs] = React.useState<Record<string, string>>({});
const [authBusyKey, setAuthBusyKey] = React.useState<string | null>(null);
const [modelQuery, setModelQuery] = React.useState('');
const [pendingOAuth, setPendingOAuth] = React.useState<{ providerId: string; methodIndex: number } | null>(null);
const [oauthCodes, setOauthCodes] = React.useState<Record<string, string>>({});
const [oauthDetails, setOauthDetails] = React.useState<Record<string, { url?: string; instructions?: string; userCode?: string }>>({});
const [availableProviders, setAvailableProviders] = React.useState<ProviderOption[]>([]);
const [availableLoading, setAvailableLoading] = React.useState(false);
const [availableError, setAvailableError] = React.useState<string | null>(null);
@@ -205,7 +192,11 @@ export const ProvidersPage: React.FC = () => {
}, [providers, selectedProviderId, setSelectedProvider]);
React.useEffect(() => {
if (!isAddMode) {
// Auth methods drive which credential UI to show (API key vs OAuth). Keep
// them loaded for the active provider view so OAuth-only plugins never fall
// back to an API key form merely because methods were never fetched, and so
// an already-listed provider can still offer re-authentication.
if (!selectedProviderId) {
return;
}
@@ -236,7 +227,7 @@ export const ProvidersPage: React.FC = () => {
return () => {
isMounted = false;
};
}, [isAddMode, t]);
}, [selectedProviderId, t]);
React.useEffect(() => {
if (!shouldLoadAvailableProviders(isAddMode)) {
@@ -323,6 +314,27 @@ export const ProvidersPage: React.FC = () => {
}
}, [selectedProviderId, editingCustomProviderId]);
// Unauthenticated providers (OAuth-only plugins before login) should open the
// auth panel instead of a false "Connected" summary.
React.useEffect(() => {
if (!selectedProviderId || selectedProviderId === ADD_PROVIDER_ID) {
return;
}
const sources = providerSources[selectedProviderId];
if (!sources) {
return;
}
const provider = providers.find((entry) => entry.id === selectedProviderId);
const envEntries = Array.isArray(provider?.env)
? provider.env.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
: [];
const hasCreds = Boolean(sources.auth.exists) || envEntries.length > 0;
const isCustomProvider = Boolean(provider && isConfigDefinedCustomProvider(provider, sources));
if (requiresProviderAuth(true, hasCreds, isCustomProvider)) {
setShowAuthPanel(true);
}
}, [selectedProviderId, providerSources, providers]);
React.useEffect(() => {
if (!selectedProviderId || selectedProviderId === ADD_PROVIDER_ID) {
return;
@@ -334,7 +346,8 @@ export const ProvidersPage: React.FC = () => {
try {
// OpenChamber-only metadata endpoint: the SDK exposes provider data but
// not local auth/source-file provenance used by this settings UI.
const response = await runtimeFetch(`/api/provider/${encodeURIComponent(selectedProviderId)}/source`, {
const query = settingsDirectory ? `?directory=${encodeURIComponent(settingsDirectory)}` : '';
const response = await runtimeFetch(`/api/provider/${encodeURIComponent(selectedProviderId)}/source${query}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
@@ -363,7 +376,7 @@ export const ProvidersPage: React.FC = () => {
return () => {
cancelled = true;
};
}, [selectedProviderId, t]);
}, [selectedProviderId, settingsDirectory, t]);
const selectedProvider = providers.find((provider) => provider.id === selectedProviderId);
const selectedSources = selectedProviderId ? providerSources[selectedProviderId] : undefined;
@@ -389,7 +402,7 @@ export const ProvidersPage: React.FC = () => {
toast.success(t('settings.providers.page.toast.apiKeySaved'));
setApiKeyInputs((prev) => ({ ...prev, [providerId]: '' }));
await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" });
recordDeferredOpenCodeRestart('providers', { id: providerId });
setSelectedProvider(providerId);
} catch (error) {
console.error('Failed to save API key:', error);
@@ -424,7 +437,7 @@ export const ProvidersPage: React.FC = () => {
? (editingCustomScope ?? resolveProviderConfigScope(providerSources[editingCustomProviderId]))
: 'user',
});
const response = await runtimeFetch('/api/provider', {
const response = await runtimeFetch(`/api/provider${settingsDirectory ? `?directory=${encodeURIComponent(settingsDirectory)}` : ''}`, {
method: 'PUT',
headers: {
Accept: 'application/json',
@@ -447,7 +460,7 @@ export const ProvidersPage: React.FC = () => {
setEditingCustomScope(null);
setCustomAuthFailureHint(null);
setLastCustomPersistId(null);
await reloadOpenCodeConfiguration({ scopes: ['providers'], mode: 'active' });
noteDeferredRestartFromPayload(payload, 'providers', { id: plan.providerID });
setSelectedProvider(plan.providerID);
} catch (error) {
console.error('Failed to save custom provider:', error);
@@ -461,117 +474,15 @@ export const ProvidersPage: React.FC = () => {
}
};
const handleOAuthStart = async (providerId: string, methodIndex: number) => {
const busyKey = `oauth:${providerId}:${methodIndex}`;
setAuthBusyKey(busyKey);
const oauthMethodFallbackLabel = (index: number) =>
t('settings.providers.page.auth.oauthMethodFallback', { index: String(index + 1) });
try {
const result = await opencodeClient.getSdkClient().provider.oauth.authorize({
providerID: providerId,
method: methodIndex,
});
if (result.error) {
throw new Error(t('settings.providers.page.toast.oauthStartFailed'));
}
const payloadRecord: Record<string, unknown> = isRecord(result.data) ? result.data : {};
const nestedData = payloadRecord.data;
const dataRecord: Record<string, unknown> = isRecord(nestedData) ? nestedData : payloadRecord;
const urlCandidate =
(typeof dataRecord.url === 'string' && dataRecord.url) ||
(typeof dataRecord.verification_uri_complete === 'string' && dataRecord.verification_uri_complete) ||
(typeof dataRecord.verification_uri === 'string' && dataRecord.verification_uri) ||
undefined;
const instructions =
(typeof dataRecord.instructions === 'string' && dataRecord.instructions) ||
(typeof dataRecord.message === 'string' && dataRecord.message) ||
undefined;
const userCode =
(typeof dataRecord.user_code === 'string' && dataRecord.user_code) ||
(typeof dataRecord.code === 'string' && dataRecord.code) ||
(typeof dataRecord.userCode === 'string' && dataRecord.userCode) ||
undefined;
if (!urlCandidate && !instructions && !userCode) {
throw new Error(t('settings.providers.page.toast.oauthDetailsMissing'));
}
const detailsKey = `${providerId}:${methodIndex}`;
setOauthDetails((prev) => ({
...prev,
[detailsKey]: {
url: urlCandidate,
instructions,
userCode,
},
}));
if (urlCandidate) {
void openExternalUrl(urlCandidate);
}
setPendingOAuth({ providerId, methodIndex });
toast.message(t('settings.providers.page.toast.completeOAuthInBrowser'));
} catch (error) {
console.error('Failed to start OAuth flow:', error);
toast.error(t('settings.providers.page.toast.oauthStartFailed'));
} finally {
setAuthBusyKey(null);
const handleOAuthConnected = (providerId: string) => {
setShowAuthPanel(false);
if (requiresOpenCodeRestartAfterOAuth(providerId)) {
recordDeferredOpenCodeRestart('providers', { id: providerId });
}
};
const handleOAuthComplete = async (providerId: string, methodIndex: number) => {
const codeKey = `${providerId}:${methodIndex}`;
const code = oauthCodes[codeKey]?.trim();
const busyKey = `oauth-complete:${providerId}:${methodIndex}`;
setAuthBusyKey(busyKey);
try {
const requestBody: { method: number; code?: string } = { method: methodIndex };
if (code) {
requestBody.code = code;
}
const result = await opencodeClient.getSdkClient().provider.oauth.callback({
providerID: providerId,
method: requestBody.method,
code: requestBody.code,
});
if (result.error) {
throw new Error(t('settings.providers.page.toast.oauthCompleteFailed'));
}
toast.success(t('settings.providers.page.toast.oauthCompleted'));
setOauthCodes((prev) => ({ ...prev, [codeKey]: '' }));
setPendingOAuth(null);
await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" });
setSelectedProvider(providerId);
} catch (error) {
console.error('Failed to complete OAuth flow:', error);
toast.error(t('settings.providers.page.toast.oauthCompleteFailed'));
} finally {
setAuthBusyKey(null);
}
};
const handleCopyOAuthLink = async (url: string) => {
const result = await copyTextToClipboard(url);
if (result.ok) {
toast.success(t('settings.providers.page.toast.oauthLinkCopied'));
return;
}
console.error('Failed to copy OAuth link:', result.error);
toast.error(t('settings.providers.page.toast.oauthLinkCopyFailed'));
};
const handleCopyOAuthCode = async (code: string) => {
const result = await copyTextToClipboard(code);
if (result.ok) {
toast.success(t('settings.providers.page.toast.deviceCodeCopied'));
return;
}
console.error('Failed to copy device code:', result.error);
toast.error(t('settings.providers.page.toast.deviceCodeCopyFailed'));
setSelectedProvider(providerId);
};
const handleDisconnectProvider = async (providerId: string) => {
@@ -579,10 +490,13 @@ export const ProvidersPage: React.FC = () => {
setAuthBusyKey(busyKey);
try {
const response = await runtimeFetch(`/api/provider/${encodeURIComponent(providerId)}/auth?scope=all`, {
method: 'DELETE',
headers: { Accept: 'application/json' },
});
const response = await runtimeFetch(
`/api/provider/${encodeURIComponent(providerId)}/auth?scope=all${settingsDirectory ? `&directory=${encodeURIComponent(settingsDirectory)}` : ''}`,
{
method: 'DELETE',
headers: { Accept: 'application/json' },
},
);
const payload = await response.json().catch(() => null);
if (!response.ok) {
@@ -590,7 +504,9 @@ export const ProvidersPage: React.FC = () => {
}
toast.success(t('settings.providers.page.toast.providerDisconnected'));
await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" });
// Only accumulate when the server actually deferred a restart (e.g. auth removed).
// removed:false payloads must not create a phantom pending Apply & Restart.
noteDeferredRestartFromPayload(payload, 'providers', { id: providerId });
} catch (error) {
console.error('Failed to disconnect provider:', error);
toast.error(t('settings.providers.page.toast.providerDisconnectFailed'));
@@ -688,15 +604,9 @@ export const ProvidersPage: React.FC = () => {
</div>
<ScrollableOverlay outerClassName="max-h-[240px]" className="p-1">
{(() => {
const query = providerSearchQuery.toLowerCase();
const customLabel = t('settings.providers.page.custom.optionLabel');
const customMatches = !query
|| customLabel.toLowerCase().includes(query)
|| 'other'.includes(query)
|| 'custom'.includes(query);
const filtered = unconnectedProviders.filter(p => {
return (p.name || p.id).toLowerCase().includes(query) || p.id.toLowerCase().includes(query);
});
const customMatches = matchesRankQuery([customLabel, 'other', 'custom'], providerSearchQuery);
const filtered = rankByQuery(unconnectedProviders, providerSearchQuery, (p) => [p.name || p.id, p.id]);
if (filtered.length === 0 && !customMatches) {
return <p className="py-4 text-center typography-meta text-muted-foreground">{t('settings.providers.page.connect.noProvidersFound')}</p>;
}
@@ -778,125 +688,57 @@ export const ProvidersPage: React.FC = () => {
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.auth.loadingMethods')}</p>
) : (
<>
<div className="py-1.5">
<label className="typography-ui-label text-foreground flex items-center gap-1.5">
{t('settings.providers.page.auth.apiKeyLabel')}
<SettingsInfoHint>{t('settings.providers.page.auth.apiKeyTooltip')}</SettingsInfoHint>
</label>
<div className="flex flex-col @xl:flex-row @xl:items-center gap-2 mt-1.5">
<Input
type="password"
value={apiKeyInputs[candidateProviderId] ?? ''}
onChange={(event) =>
setApiKeyInputs((prev) => ({
...prev,
[candidateProviderId]: event.target.value,
}))
}
placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')}
className="flex-1 font-mono text-xs"
/>
<Button
size="xs"
className="!font-normal shrink-0"
onClick={() => handleSaveApiKey(candidateProviderId)}
disabled={authBusyKey === `api:${candidateProviderId}`}
>
{authBusyKey === `api:${candidateProviderId}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.saveKey')}
</Button>
</div>
</div>
{(() => {
const candidateAuthMethods = authMethodsByProvider[candidateProviderId] ?? [];
const candidateOAuthMethods = candidateAuthMethods.filter(
(method) => normalizeAuthType(method) === 'oauth'
const candidateOAuthMethods = toOAuthMethods(
getOAuthAuthMethods(candidateAuthMethods),
oauthMethodFallbackLabel,
);
if (candidateOAuthMethods.length === 0) {
return null;
}
const showApiKey = shouldShowApiKeyAuth(candidateAuthMethods);
return (
<div className="space-y-4 border-t border-[var(--surface-subtle)] pt-2">
{candidateOAuthMethods.map((method, index) => {
const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(index + 1) });
const codeKey = `${candidateProviderId}:${index}`;
const isPending =
pendingOAuth?.providerId === candidateProviderId && pendingOAuth?.methodIndex === index;
return (
<div key={`${candidateProviderId}-${methodLabel}`} className="space-y-3">
<div className="flex items-center justify-between gap-2">
<div>
<div className="typography-ui-label text-foreground">{methodLabel}</div>
{(method.description || method.help) && (
<div className="typography-meta text-muted-foreground">
{String(method.description || method.help)}
</div>
)}
</div>
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => handleOAuthStart(candidateProviderId, index)}
disabled={authBusyKey === `oauth:${candidateProviderId}:${index}`}
>
{t('settings.providers.page.actions.connect')}
</Button>
</div>
{oauthDetails[codeKey]?.instructions && (
<p className="typography-meta text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-2 py-1.5 rounded">
{oauthDetails[codeKey]?.instructions}
</p>
)}
{oauthDetails[codeKey]?.userCode && (
<div className="flex items-center gap-2 mt-2">
<Input value={oauthDetails[codeKey]?.userCode} readOnly className="font-mono text-center tracking-widest" />
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>{t('settings.providers.page.actions.copyCode')}</Button>
</div>
)}
{oauthDetails[codeKey]?.url && (
<div className="flex items-center gap-2 mt-2">
<Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" />
<div className="flex gap-1 shrink-0">
<Button variant="outline" size="xs" className="!font-normal" onClick={() => openExternalUrl(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.open')}</Button>
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.copy')}</Button>
</div>
</div>
)}
{isPending && (
<div className="flex items-center gap-2 mt-2">
<Input
value={oauthCodes[codeKey] ?? ''}
onChange={(event) =>
setOauthCodes((prev) => ({
...prev,
[codeKey]: event.target.value,
}))
}
placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')}
className="font-mono text-xs"
/>
<Button
size="xs"
className="!font-normal"
onClick={() => handleOAuthComplete(candidateProviderId, index)}
disabled={authBusyKey === `oauth-complete:${candidateProviderId}:${index}`}
>
{authBusyKey === `oauth-complete:${candidateProviderId}:${index}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.complete')}
</Button>
</div>
)}
<>
{showApiKey ? (
<div className="py-1.5">
<label className="typography-ui-label text-foreground flex items-center gap-1.5">
{t('settings.providers.page.auth.apiKeyLabel')}
<SettingsInfoHint>{t('settings.providers.page.auth.apiKeyTooltip')}</SettingsInfoHint>
</label>
<div className="flex flex-col @xl:flex-row @xl:items-center gap-2 mt-1.5">
<Input
type="password"
value={apiKeyInputs[candidateProviderId] ?? ''}
onChange={(event) =>
setApiKeyInputs((prev) => ({
...prev,
[candidateProviderId]: event.target.value,
}))
}
placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')}
className="flex-1 font-mono text-xs"
/>
<Button
size="xs"
className="!font-normal shrink-0"
onClick={() => handleSaveApiKey(candidateProviderId)}
disabled={authBusyKey === `api:${candidateProviderId}`}
>
{authBusyKey === `api:${candidateProviderId}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.saveKey')}
</Button>
</div>
);
})}
</div>
</div>
) : null}
{candidateOAuthMethods.length > 0 ? (
<ProviderOAuthMethods
key={candidateProviderId}
providerId={candidateProviderId}
methods={candidateOAuthMethods}
onConnected={() => handleOAuthConnected(candidateProviderId)}
className={cn(showApiKey && 'border-t border-[var(--surface-subtle)] pt-2')}
/>
) : null}
</>
);
})()}
</>
@@ -921,7 +763,11 @@ export const ProvidersPage: React.FC = () => {
const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : [];
const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? [];
const oauthAuthMethods = providerAuthMethods.filter((method) => normalizeAuthType(method) === 'oauth');
const oauthAuthMethods = toOAuthMethods(
getOAuthAuthMethods(providerAuthMethods),
oauthMethodFallbackLabel,
);
const showApiKeyAuth = shouldShowApiKeyAuth(providerAuthMethods);
const sourcesLoaded = Boolean(selectedSources);
const isEditableCustomProvider = sourcesLoaded
&& isConfigDefinedCustomProvider(selectedProvider, selectedSources);
@@ -931,15 +777,20 @@ export const ProvidersPage: React.FC = () => {
const hasStoredAuth = Boolean(selectedSources?.auth.exists);
const hasEnvCredentials = providerEnv.length > 0;
const hasCredentials = hasStoredAuth || hasEnvCredentials;
const authStatusIncomplete = isEditableCustomProvider && !hasCredentials;
const authStatusIncomplete = requiresProviderAuth(
sourcesLoaded,
hasCredentials,
isEditableCustomProvider,
);
const showModelsSection = providerModels.length > 0 && !authStatusIncomplete;
const incompleteAuthHint = !showApiKeyAuth && oauthAuthMethods.length > 0
? t('settings.providers.page.auth.useReconnectHint')
: t('settings.providers.page.auth.incompleteHint');
const filteredModels = providerModels.filter((model) => {
const name = typeof model?.name === 'string' ? model.name : '';
const id = typeof model?.id === 'string' ? model.id : '';
const query = modelQuery.trim().toLowerCase();
if (!query) return true;
return name.toLowerCase().includes(query) || id.toLowerCase().includes(query);
});
const filteredModels = rankByQuery(providerModels, modelQuery, (model) => [
typeof model?.name === 'string' ? model.name : '',
typeof model?.id === 'string' ? model.id : '',
]);
if (isCustomEditMode && isEditableCustomProvider && editingCustomFormInitial) {
return (
@@ -1014,7 +865,7 @@ export const ProvidersPage: React.FC = () => {
<div className="flex items-center gap-1.5 py-1.5">
<Icon name="alert" className="w-4 h-4 text-[var(--status-warning)] shrink-0" />
<span className="typography-ui-label text-foreground">{t('settings.providers.page.auth.incomplete')}</span>
<SettingsInfoHint>{t('settings.providers.page.auth.incompleteHint')}</SettingsInfoHint>
<SettingsInfoHint>{incompleteAuthHint}</SettingsInfoHint>
</div>
) : (
<div className="flex items-center gap-1.5 py-1.5">
@@ -1027,115 +878,45 @@ export const ProvidersPage: React.FC = () => {
<div className="py-1.5 typography-meta text-muted-foreground">{t('settings.providers.page.auth.loadingMethods')}</div>
) : (
<div className="space-y-4">
<div className="py-1.5">
<label className="typography-ui-label text-foreground flex items-center gap-1.5">
{t('settings.providers.page.auth.apiKeyLabel')}
<SettingsInfoHint>{t('settings.providers.page.auth.apiKeyTooltip')}</SettingsInfoHint>
</label>
<div className="flex flex-col @xl:flex-row @xl:items-center gap-2 mt-1.5">
<Input
type="password"
value={apiKeyInputs[selectedProvider.id] ?? ''}
onChange={(event) =>
setApiKeyInputs((prev) => ({
...prev,
[selectedProvider.id]: event.target.value,
}))
}
placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')}
className="flex-1 font-mono text-xs"
/>
<Button
size="xs"
className="!font-normal shrink-0"
onClick={() => handleSaveApiKey(selectedProvider.id)}
disabled={authBusyKey === `api:${selectedProvider.id}`}
>
{authBusyKey === `api:${selectedProvider.id}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.saveKey')}
</Button>
{showApiKeyAuth ? (
<div className="py-1.5">
<label className="typography-ui-label text-foreground flex items-center gap-1.5">
{t('settings.providers.page.auth.apiKeyLabel')}
<SettingsInfoHint>{t('settings.providers.page.auth.apiKeyTooltip')}</SettingsInfoHint>
</label>
<div className="flex flex-col @xl:flex-row @xl:items-center gap-2 mt-1.5">
<Input
type="password"
value={apiKeyInputs[selectedProvider.id] ?? ''}
onChange={(event) =>
setApiKeyInputs((prev) => ({
...prev,
[selectedProvider.id]: event.target.value,
}))
}
placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')}
className="flex-1 font-mono text-xs"
/>
<Button
size="xs"
className="!font-normal shrink-0"
onClick={() => handleSaveApiKey(selectedProvider.id)}
disabled={authBusyKey === `api:${selectedProvider.id}`}
>
{authBusyKey === `api:${selectedProvider.id}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.saveKey')}
</Button>
</div>
</div>
</div>
) : null}
{oauthAuthMethods.length > 0 && (
<div className="space-y-4 border-t border-[var(--surface-subtle)] pt-2">
{oauthAuthMethods.map((method, index) => {
const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(index + 1) });
const codeKey = `${selectedProvider.id}:${index}`;
const isPending =
pendingOAuth?.providerId === selectedProvider.id && pendingOAuth?.methodIndex === index;
return (
<div key={`${selectedProvider.id}-${methodLabel}`} className="space-y-3">
<div className="flex items-center justify-between gap-2">
<div>
<div className="typography-ui-label text-foreground">{methodLabel}</div>
{(method.description || method.help) && (
<div className="typography-meta text-muted-foreground">
{String(method.description || method.help)}
</div>
)}
</div>
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => handleOAuthStart(selectedProvider.id, index)}
disabled={authBusyKey === `oauth:${selectedProvider.id}:${index}`}
>
{t('settings.providers.page.actions.connect')}
</Button>
</div>
{oauthDetails[codeKey]?.instructions && (
<p className="typography-meta text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-2 py-1.5 rounded">
{oauthDetails[codeKey]?.instructions}
</p>
)}
{oauthDetails[codeKey]?.userCode && (
<div className="flex items-center gap-2 mt-2">
<Input value={oauthDetails[codeKey]?.userCode} readOnly className="font-mono text-center tracking-widest" />
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>{t('settings.providers.page.actions.copyCode')}</Button>
</div>
)}
{oauthDetails[codeKey]?.url && (
<div className="flex items-center gap-2 mt-2">
<Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" />
<div className="flex gap-1 shrink-0">
<Button variant="outline" size="xs" className="!font-normal" onClick={() => openExternalUrl(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.open')}</Button>
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.copy')}</Button>
</div>
</div>
)}
{isPending && (
<div className="flex items-center gap-2 mt-2">
<Input
value={oauthCodes[codeKey] ?? ''}
onChange={(event) =>
setOauthCodes((prev) => ({
...prev,
[codeKey]: event.target.value,
}))
}
placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')}
className="font-mono text-xs"
/>
<Button
size="xs"
className="!font-normal"
onClick={() => handleOAuthComplete(selectedProvider.id, index)}
disabled={authBusyKey === `oauth-complete:${selectedProvider.id}:${index}`}
>
{authBusyKey === `oauth-complete:${selectedProvider.id}:${index}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.complete')}
</Button>
</div>
)}
</div>
);
})}
</div>
<ProviderOAuthMethods
key={selectedProvider.id}
providerId={selectedProvider.id}
methods={oauthAuthMethods}
onConnected={() => handleOAuthConnected(selectedProvider.id)}
className={cn(showApiKeyAuth && 'border-t border-[var(--surface-subtle)] pt-2')}
/>
)}
</div>
)}
@@ -1175,14 +956,13 @@ export const ProvidersPage: React.FC = () => {
</div>
</SettingsSection>
{showModelsSection ? (
<SettingsSection
title={t('settings.providers.page.models.title')}
titleAccessory={
providerModels.length > 0 ? (
<span className="typography-micro text-muted-foreground font-normal">
({providerModels.length})
</span>
) : null
<span className="typography-micro text-muted-foreground font-normal">
({providerModels.length})
</span>
}
headerAction={(
<div className="flex items-center gap-1">
@@ -1291,6 +1071,7 @@ export const ProvidersPage: React.FC = () => {
</div>
)}
</SettingsSection>
) : null}
</SettingsPageLayout>
);
};
@@ -2,7 +2,8 @@ import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { Button } from '@/components/ui/button';
import { useConfigStore } from '@/stores/useConfigStore';
import { selectProvidersForDirectory, useConfigStore } from '@/stores/useConfigStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { cn } from '@/lib/utils';
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
@@ -40,16 +41,28 @@ interface ProvidersSidebarProps {
export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect }) => {
const { t } = useI18n();
const providers = useConfigStore((state) => state.providers);
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const providers = useConfigStore((state) => selectProvidersForDirectory(state, settingsDirectory));
const selectedProviderId = useConfigStore((state) => state.selectedProviderId);
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
const [sourcesByProvider, setSourcesByProvider] = React.useState<Record<string, ProviderSources>>({});
const directory = React.useMemo(() => {
if (settingsDirectory) return settingsDirectory;
// tie refresh to active project changes (directory is stored in the client)
void activeProjectId;
return getCurrentDirectory();
}, [activeProjectId]);
}, [activeProjectId, settingsDirectory]);
// The app only loads providers for the project it is on; Settings has to ask
// for the one it is looking at.
const loadProviders = useConfigStore((state) => state.loadProviders);
React.useEffect(() => {
if (!settingsDirectory) return;
void loadProviders({ directory: settingsDirectory, source: 'settings:providers' });
}, [loadProviders, settingsDirectory]);
React.useEffect(() => {
if (providers.length === 0) {
@@ -16,6 +16,7 @@ const t = (key: string) => key;
const baseForm = (overrides: Partial<CustomProviderFormState> = {}): CustomProviderFormState => ({
providerID: 'custom-provider',
name: 'Custom Provider',
protocol: 'openai-chat',
baseURL: 'https://api.example.com/v1',
apiKey: 'sk-test',
models: [{ row: 'm0', id: 'model-a', name: 'Model A' }],
@@ -96,6 +97,16 @@ describe('validateCustomProvider', () => {
expect(result.result?.config.env).toEqual(['CUSTOM_PROVIDER_KEY']);
});
test('uses the selected OpenCode provider adapter', () => {
const result = validateCustomProvider({
form: baseForm({ protocol: 'openai-responses' }),
t,
existingProviderIDs: new Set(),
});
expect(result.result?.config.npm).toBe('@ai-sdk/openai');
});
test('rejects missing credentials', () => {
const result = validateCustomProvider({
form: baseForm({ apiKey: ' ' }),
@@ -300,10 +311,21 @@ describe('provider edit helpers', () => {
expect(state.name).toBe('Campus LLM');
expect(state.baseURL).toBe('https://llm.example.edu/v1');
expect(state.apiKey).toBe('{env:CAMPUS_KEY}');
expect(state.protocol).toBe('openai-chat');
expect(state.models[0]).toEqual({ row: state.models[0].row, id: 'fast', name: 'Fast' });
expect(state.headers[0]).toEqual({ row: state.headers[0].row, key: 'X-Campus', value: '1' });
});
test('prefills the protocol from a custom provider model', () => {
const state = providerToCustomFormState({
id: 'responses-api',
options: { baseURL: 'https://api.example.com/v1' },
models: [{ id: 'gpt', name: 'GPT', api: { npm: '@ai-sdk/openai' } }],
});
expect(state.protocol).toBe('openai-responses');
});
test('requires a config-layer source before treating a provider as editable custom', () => {
const catalogLike = {
id: 'openai',
@@ -1,14 +1,20 @@
/**
* Custom / Other OpenAI-compatible provider form helpers.
* Custom provider form helpers.
* Mirrors OpenCode web UI validation and request construction so a provider
* can be defined from Settings without code changes.
*/
export const CUSTOM_PROVIDER_NPM = '@ai-sdk/openai-compatible';
export const CUSTOM_PROVIDER_PROTOCOLS = {
'openai-chat': '@ai-sdk/openai-compatible',
'openai-responses': '@ai-sdk/openai',
'anthropic-messages': '@ai-sdk/anthropic',
} as const;
export type CustomProviderProtocol = keyof typeof CUSTOM_PROVIDER_PROTOCOLS;
export type CustomProviderNpm = (typeof CUSTOM_PROVIDER_PROTOCOLS)[CustomProviderProtocol];
export const CUSTOM_PROVIDER_ID = '__custom_provider__';
export const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
export const BASE_URL_PATTERN = /^https?:\/\//;
export const ENV_KEY_PATTERN = /^\{env:([^}]+)\}$/;
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
const BASE_URL_PATTERN = /^https?:\/\//;
const ENV_KEY_PATTERN = /^\{env:([^}]+)\}$/;
export type CustomProviderTranslator = (
key: string,
@@ -30,6 +36,7 @@ export type HeaderRow = {
export type CustomProviderFormState = {
providerID: string;
name: string;
protocol: CustomProviderProtocol;
baseURL: string;
apiKey: string;
models: ModelRow[];
@@ -54,7 +61,7 @@ export type HeaderFieldErrors = {
};
export type CustomProviderConfig = {
npm: typeof CUSTOM_PROVIDER_NPM;
npm: CustomProviderNpm;
name: string;
env?: string[];
options: {
@@ -120,13 +127,25 @@ export const createHeaderRow = (): HeaderRow => ({
export const createEmptyCustomProviderForm = (): CustomProviderFormState => ({
providerID: '',
name: '',
protocol: 'openai-chat',
baseURL: '',
apiKey: '',
models: [createModelRow()],
headers: [createHeaderRow()],
});
export function parseEnvApiKey(apiKey: string): { env?: string; key?: string } {
function protocolFromNpm(npm: string | undefined): CustomProviderProtocol {
switch (npm) {
case '@ai-sdk/openai':
return 'openai-responses';
case '@ai-sdk/anthropic':
return 'anthropic-messages';
default:
return 'openai-chat';
}
}
function parseEnvApiKey(apiKey: string): { env?: string; key?: string } {
const trimmed = apiKey.trim();
if (!trimmed) {
return {};
@@ -159,7 +178,7 @@ export function isCustomOpenAICompatibleProvider(provider: ProviderLikeForCustom
const api = 'api' in model && model.api && typeof model.api === 'object'
? model.api as { npm?: unknown }
: null;
return typeof api?.npm === 'string' && api.npm === CUSTOM_PROVIDER_NPM;
return typeof api?.npm === 'string' && new Set<string>(Object.values(CUSTOM_PROVIDER_PROTOCOLS)).has(api.npm);
});
}
@@ -238,9 +257,14 @@ export function providerToCustomFormState(provider: ProviderLikeForCustomForm):
? provider.env.find((entry) => typeof entry === 'string' && entry.trim().length > 0)?.trim()
: undefined;
const modelWithApi = modelEntries.find(
(model): model is { id?: string; name?: string; api?: { npm?: string } } => 'api' in model,
);
return {
providerID: provider.id,
name: typeof provider.name === 'string' && provider.name.trim() ? provider.name : provider.id,
protocol: protocolFromNpm(modelWithApi?.api?.npm),
baseURL,
apiKey: envName ? `{env:${envName}}` : '',
models,
@@ -360,7 +384,7 @@ export function validateCustomProvider(input: ValidateCustomProviderInput): Vali
name,
apiKey: key,
config: {
npm: CUSTOM_PROVIDER_NPM,
npm: CUSTOM_PROVIDER_PROTOCOLS[input.form.protocol],
name,
...(env ? { env: [env] } : {}),
options: {
@@ -0,0 +1,244 @@
import { describe, expect, test } from 'bun:test';
import {
collectPromptInputs,
defaultPromptValues,
describeOAuthError,
firstUnansweredPrompt,
isPromptVisible,
parseAuthPrompts,
parseAuthorization,
shouldOpenAuthorizationUrl,
visiblePrompts,
type AuthPrompt,
type ProviderOAuthTranslator,
} from './provider-oauth';
describe('shouldOpenAuthorizationUrl', () => {
test('lets Claude Code CLI own browser launch', () => {
expect(shouldOpenAuthorizationUrl('claude-code', 'https://docs.example')).toBe(false);
expect(shouldOpenAuthorizationUrl('github-copilot', 'https://github.com/login')).toBe(true);
});
});
/** Mirrors the github-copilot auth method shipped by OpenCode. */
const copilotPrompts = [
{
type: 'select',
key: 'deploymentType',
message: 'Select GitHub deployment type',
options: [
{ label: 'GitHub.com', value: 'github.com', hint: 'Public' },
{ label: 'GitHub Enterprise', value: 'enterprise' },
],
},
{
type: 'text',
key: 'enterpriseUrl',
message: 'Enter your GitHub Enterprise URL or domain',
placeholder: 'company.ghe.com',
when: { key: 'deploymentType', op: 'eq', value: 'enterprise' },
},
];
describe('parseAuthPrompts', () => {
test('parses select and conditional text prompts', () => {
const prompts = parseAuthPrompts(copilotPrompts);
expect(prompts).toHaveLength(2);
expect(prompts[0]).toEqual({
type: 'select',
key: 'deploymentType',
message: 'Select GitHub deployment type',
options: [
{ value: 'github.com', label: 'GitHub.com', hint: 'Public' },
{ value: 'enterprise', label: 'GitHub Enterprise' },
],
});
expect(prompts[1]).toEqual({
type: 'text',
key: 'enterpriseUrl',
message: 'Enter your GitHub Enterprise URL or domain',
options: [],
placeholder: 'company.ghe.com',
when: { key: 'deploymentType', op: 'eq', value: 'enterprise' },
});
});
test('returns an empty list for a method without prompts', () => {
expect(parseAuthPrompts(undefined)).toEqual([]);
expect(parseAuthPrompts(null)).toEqual([]);
expect(parseAuthPrompts({})).toEqual([]);
});
test('drops entries that could never be answered', () => {
const prompts = parseAuthPrompts([
{ type: 'text', message: 'no key' },
{ type: 'select', key: 'empty', message: 'no options', options: [] },
{ type: 'text', key: 'keep', message: 'keep me' },
]);
expect(prompts.map((prompt) => prompt.key)).toEqual(['keep']);
});
test('falls back to the key when a message is missing', () => {
expect(parseAuthPrompts([{ type: 'text', key: 'token' }])[0]?.message).toBe('token');
});
test('ignores a malformed when condition instead of hiding the prompt', () => {
const [prompt] = parseAuthPrompts([
{ type: 'text', key: 'url', message: 'URL', when: { key: 'other', op: 'contains', value: 'x' } },
]);
expect(prompt.when).toBe(undefined);
expect(isPromptVisible(prompt, {})).toBe(true);
});
});
describe('prompt visibility', () => {
const prompts = parseAuthPrompts(copilotPrompts);
test('hides a conditional prompt until its branch is selected', () => {
expect(visiblePrompts(prompts, { deploymentType: 'github.com' }).map((p) => p.key))
.toEqual(['deploymentType']);
expect(visiblePrompts(prompts, { deploymentType: 'enterprise' }).map((p) => p.key))
.toEqual(['deploymentType', 'enterpriseUrl']);
});
test('supports neq conditions', () => {
const prompt: AuthPrompt = {
type: 'text',
key: 'custom',
message: 'Custom',
options: [],
when: { key: 'mode', op: 'neq', value: 'default' },
};
expect(isPromptVisible(prompt, { mode: 'default' })).toBe(false);
expect(isPromptVisible(prompt, { mode: 'other' })).toBe(true);
expect(isPromptVisible(prompt, {})).toBe(true);
});
});
describe('prompt answers', () => {
const prompts = parseAuthPrompts(copilotPrompts);
test('preselects the first select option so the form starts answerable', () => {
expect(defaultPromptValues(prompts)).toEqual({ deploymentType: 'github.com', enterpriseUrl: '' });
expect(firstUnansweredPrompt(prompts, defaultPromptValues(prompts))).toBeNull();
});
test('reports the hidden-then-revealed field as unanswered', () => {
const values = { deploymentType: 'enterprise', enterpriseUrl: ' ' };
expect(firstUnansweredPrompt(prompts, values)?.key).toBe('enterpriseUrl');
});
test('omits answers whose prompt is no longer visible', () => {
const values = { deploymentType: 'github.com', enterpriseUrl: 'left-over.ghe.com' };
expect(collectPromptInputs(prompts, values)).toEqual({ deploymentType: 'github.com' });
});
test('trims submitted answers', () => {
const values = { deploymentType: 'enterprise', enterpriseUrl: ' company.ghe.com ' };
expect(collectPromptInputs(prompts, values)).toEqual({
deploymentType: 'enterprise',
enterpriseUrl: 'company.ghe.com',
});
});
});
describe('parseAuthorization', () => {
test('reads a device-code authorization and recovers the code from instructions', () => {
const authorization = parseAuthorization({
url: 'https://github.com/login/device',
instructions: 'Enter code: 1A2B-3C4D',
method: 'auto',
});
expect(authorization).toEqual({
method: 'auto',
url: 'https://github.com/login/device',
instructions: 'Enter code: 1A2B-3C4D',
userCode: '1A2B-3C4D',
});
});
test('keeps an explicitly reported code over the instructions match', () => {
expect(parseAuthorization({
url: 'https://example.com',
instructions: 'Enter code: AAAA-BBBB',
user_code: 'ZZZZ-9999',
method: 'auto',
})?.userCode).toBe('ZZZZ-9999');
});
test('preserves the code method', () => {
expect(parseAuthorization({ url: 'https://example.com', method: 'code' })?.method).toBe('code');
});
test('treats a missing or unknown method as auto', () => {
expect(parseAuthorization({ url: 'https://example.com' })?.method).toBe('auto');
expect(parseAuthorization({ url: 'https://example.com', method: 'device' })?.method).toBe('auto');
});
test('unwraps a nested data envelope', () => {
expect(parseAuthorization({ data: { url: 'https://example.com', method: 'code' } })).toEqual({
method: 'code',
url: 'https://example.com',
});
});
test('accepts device-authorization field names', () => {
expect(parseAuthorization({
verification_uri_complete: 'https://example.com/activate?code=1',
message: 'Open the link',
})).toEqual({
method: 'auto',
url: 'https://example.com/activate?code=1',
instructions: 'Open the link',
});
});
test('returns null when nothing is actionable', () => {
expect(parseAuthorization(null)).toBeNull();
expect(parseAuthorization({})).toBeNull();
expect(parseAuthorization({ method: 'auto' })).toBeNull();
});
});
describe('describeOAuthError', () => {
const t: ProviderOAuthTranslator = (key) => key;
const fallback = 'settings.providers.page.toast.oauthCompleteFailed';
/** Names come from OpenCode's ProviderAuthApiError schema. */
test('maps each provider auth error name to its own message', () => {
expect(describeOAuthError({ name: 'ProviderAuthOauthMissing', data: {} }, t, fallback))
.toBe('settings.providers.page.auth.oauth.error.sessionExpired');
expect(describeOAuthError({ name: 'ProviderAuthOauthCodeMissing', data: {} }, t, fallback))
.toBe('settings.providers.page.auth.oauth.error.codeRequired');
expect(describeOAuthError({ name: 'ProviderAuthOauthCallbackFailed', data: {} }, t, fallback))
.toBe('settings.providers.page.auth.oauth.error.declined');
});
test('surfaces the plugin-authored validation message verbatim', () => {
const error = {
name: 'ProviderAuthValidationFailed',
data: { field: 'enterpriseUrl', message: 'URL or domain is required' },
};
expect(describeOAuthError(error, t, fallback)).toBe('URL or domain is required');
});
test('falls back when a validation failure carries no message', () => {
expect(describeOAuthError({ name: 'ProviderAuthValidationFailed', data: {} }, t, fallback))
.toBe('settings.providers.page.auth.oauth.error.invalidInput');
});
test('falls back for unknown, empty, and non-object errors', () => {
expect(describeOAuthError({ name: 'BadRequest', data: {} }, t, fallback)).toBe(fallback);
expect(describeOAuthError(new Error('network down'), t, fallback)).toBe(fallback);
expect(describeOAuthError(undefined, t, fallback)).toBe(fallback);
});
});
@@ -0,0 +1,247 @@
/**
* Provider OAuth flow helpers.
*
* `POST /provider/{id}/oauth/authorize` answers with the completion method that
* decides what the client has to do next:
*
* - `auto` the client must call `oauth/callback` right away and hold that
* request open. Upstream blocks inside it (device-code polling, or waiting on
* a loopback redirect) until the user finishes signing in, and only then
* persists the credential. Nothing is stored if the client never calls it.
* - `code` the user copies a code out of the browser and hands it to
* `oauth/callback`.
*
* Every auth plugin shipped with OpenCode uses `auto`; `code` stays supported
* for third-party auth plugins that still return it.
*/
import type { I18nKey, I18nParams } from '@/lib/i18n';
export type OAuthCompletionMethod = 'auto' | 'code';
export type ProviderOAuthTranslator = (key: I18nKey, params?: I18nParams) => string;
export interface OAuthAuthorization {
method: OAuthCompletionMethod;
url?: string;
instructions?: string;
/** Device code surfaced separately so it can be copied on its own. */
userCode?: string;
}
export const shouldOpenAuthorizationUrl = (providerId: string, url?: string): boolean =>
Boolean(url) && providerId !== 'claude-code';
export interface AuthPromptOption {
label: string;
value: string;
hint?: string;
}
export interface AuthPromptCondition {
key: string;
op: 'eq' | 'neq';
value: string;
}
export interface AuthPrompt {
type: 'text' | 'select';
key: string;
message: string;
placeholder?: string;
options: AuthPromptOption[];
when?: AuthPromptCondition;
}
/**
* Device codes are only carried inside the human-readable instructions
* (`Enter code: ABCD-1234`), so they are recovered by shape.
*/
const DEVICE_CODE_PATTERN = /[A-Z0-9]{4}-[A-Z0-9]{4,5}/;
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const asText = (value: unknown): string | undefined =>
typeof value === 'string' && value.length > 0 ? value : undefined;
const parsePromptOptions = (value: unknown): AuthPromptOption[] => {
if (!Array.isArray(value)) {
return [];
}
const options: AuthPromptOption[] = [];
for (const entry of value) {
if (!isRecord(entry)) {
continue;
}
const optionValue = asText(entry.value);
if (optionValue === undefined) {
continue;
}
options.push({
value: optionValue,
label: asText(entry.label) ?? optionValue,
...(asText(entry.hint) ? { hint: asText(entry.hint)! } : {}),
});
}
return options;
};
const parsePromptCondition = (value: unknown): AuthPromptCondition | undefined => {
if (!isRecord(value)) {
return undefined;
}
const key = asText(value.key);
const op = value.op === 'eq' || value.op === 'neq' ? value.op : undefined;
if (!key || !op || typeof value.value !== 'string') {
return undefined;
}
return { key, op, value: value.value };
};
/** Parses the `prompts` an auth method wants answered before `authorize`. */
export const parseAuthPrompts = (value: unknown): AuthPrompt[] => {
if (!Array.isArray(value)) {
return [];
}
const prompts: AuthPrompt[] = [];
for (const entry of value) {
if (!isRecord(entry)) {
continue;
}
const key = asText(entry.key);
if (!key) {
continue;
}
const type = entry.type === 'select' ? 'select' : 'text';
const options = type === 'select' ? parsePromptOptions(entry.options) : [];
// A select with no usable option can never be answered; skipping it would
// silently drop a required input, so treat the whole method as unusable.
if (type === 'select' && options.length === 0) {
continue;
}
const when = parsePromptCondition(entry.when);
prompts.push({
type,
key,
message: asText(entry.message) ?? key,
options,
...(asText(entry.placeholder) ? { placeholder: asText(entry.placeholder)! } : {}),
...(when ? { when } : {}),
});
}
return prompts;
};
/** True when a prompt's `when` condition is satisfied by the answers so far. */
export const isPromptVisible = (prompt: AuthPrompt, values: Record<string, string>): boolean => {
if (!prompt.when) {
return true;
}
const current = values[prompt.when.key] ?? '';
return prompt.when.op === 'eq'
? current === prompt.when.value
: current !== prompt.when.value;
};
export const visiblePrompts = (
prompts: AuthPrompt[],
values: Record<string, string>,
): AuthPrompt[] => prompts.filter((prompt) => isPromptVisible(prompt, values));
/** Selects preselect their first option so the form always starts answerable. */
export const defaultPromptValues = (prompts: AuthPrompt[]): Record<string, string> => {
const values: Record<string, string> = {};
for (const prompt of prompts) {
values[prompt.key] = prompt.type === 'select' ? (prompt.options[0]?.value ?? '') : '';
}
return values;
};
/** First visible prompt still left blank, or `null` when the form is complete. */
export const firstUnansweredPrompt = (
prompts: AuthPrompt[],
values: Record<string, string>,
): AuthPrompt | null =>
visiblePrompts(prompts, values).find((prompt) => (values[prompt.key] ?? '').trim().length === 0) ?? null;
/**
* Builds the `inputs` payload for `authorize`. Hidden prompts are dropped so a
* stale answer from a since-changed branch is never sent upstream.
*/
export const collectPromptInputs = (
prompts: AuthPrompt[],
values: Record<string, string>,
): Record<string, string> => {
const inputs: Record<string, string> = {};
for (const prompt of visiblePrompts(prompts, values)) {
inputs[prompt.key] = (values[prompt.key] ?? '').trim();
}
return inputs;
};
/**
* Normalizes an `authorize` response.
*
* Anything that is not explicitly `code` is treated as `auto`: `auto` only
* means "call back and wait", which is also the safe reading of an unknown
* method, whereas guessing `code` would strand the user at a paste field no
* provider can fill.
*
* Returns `null` when the response carries nothing the user can act on.
*/
export const parseAuthorization = (payload: unknown): OAuthAuthorization | null => {
const outer: Record<string, unknown> = isRecord(payload) ? payload : {};
const record: Record<string, unknown> = isRecord(outer.data) ? outer.data : outer;
const url =
asText(record.url)
?? asText(record.verification_uri_complete)
?? asText(record.verification_uri);
const instructions = asText(record.instructions) ?? asText(record.message);
if (!url && !instructions) {
return null;
}
const userCode =
asText(record.user_code)
?? asText(record.userCode)
?? (instructions ? DEVICE_CODE_PATTERN.exec(instructions)?.[0] : undefined);
return {
method: record.method === 'code' ? 'code' : 'auto',
...(url ? { url } : {}),
...(instructions ? { instructions } : {}),
...(userCode ? { userCode } : {}),
};
};
/**
* Renders a `ProviderAuthApiError` as user-facing copy.
*
* Validation failures carry a message authored by the auth plugin (a field
* rule such as "URL or domain is required"); it is shown verbatim because only
* the plugin knows which input was rejected.
*/
export const describeOAuthError = (
error: unknown,
t: ProviderOAuthTranslator,
fallbackKey: I18nKey,
): string => {
const record: Record<string, unknown> = isRecord(error) ? error : {};
const data: Record<string, unknown> = isRecord(record.data) ? record.data : {};
switch (record.name) {
case 'ProviderAuthOauthMissing':
return t('settings.providers.page.auth.oauth.error.sessionExpired');
case 'ProviderAuthOauthCodeMissing':
return t('settings.providers.page.auth.oauth.error.codeRequired');
case 'ProviderAuthOauthCallbackFailed':
return t('settings.providers.page.auth.oauth.error.declined');
case 'ProviderAuthValidationFailed':
return asText(data.message) ?? t('settings.providers.page.auth.oauth.error.invalidInput');
default:
return t(fallbackKey);
}
};
@@ -0,0 +1,62 @@
export interface AuthMethod {
type?: string;
name?: string;
label?: string;
description?: string;
help?: string;
method?: number;
/** Inputs an OAuth method wants answered before authorize; see `provider-oauth.ts`. */
prompts?: unknown;
[key: string]: unknown;
}
export interface OAuthAuthMethodEntry {
method: AuthMethod;
/** Index in the full provider auth-methods array (passed to oauth authorize/callback). */
methodIndex: number;
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
export const normalizeAuthType = (method: AuthMethod): string => {
const raw = typeof method.type === 'string' ? method.type : '';
const label = `${method.name ?? ''} ${method.label ?? ''}`.toLowerCase();
const merged = `${raw} ${label}`.toLowerCase();
if (merged.includes('oauth')) return 'oauth';
if (merged.includes('api')) return 'api';
return raw.toLowerCase();
};
export const parseAuthPayload = (payload: unknown): Record<string, AuthMethod[]> => {
if (!isRecord(payload)) {
return {};
}
const result: Record<string, AuthMethod[]> = {};
for (const [providerId, value] of Object.entries(payload)) {
if (Array.isArray(value)) {
result[providerId] = value.filter((entry) => isRecord(entry)) as AuthMethod[];
}
}
return result;
};
/**
* Show the API key form when the provider declares API auth, or when auth
* methods are still unknown (empty). OAuth-only providers must not get an
* API key prompt.
*/
export const shouldShowApiKeyAuth = (methods: AuthMethod[]): boolean => {
if (methods.length === 0) {
return true;
}
return methods.some((method) => normalizeAuthType(method) === 'api');
};
export const getOAuthAuthMethods = (methods: AuthMethod[]): OAuthAuthMethodEntry[] =>
methods
.map((method, methodIndex) => ({ method, methodIndex }))
.filter(({ method }) => normalizeAuthType(method) === 'oauth');
export const requiresOpenCodeRestartAfterOAuth = (providerId: string): boolean =>
providerId !== 'claude-code';
@@ -1 +1,7 @@
export const shouldLoadAvailableProviders = (isAddMode: boolean): boolean => isAddMode;
export const requiresProviderAuth = (
sourcesLoaded: boolean,
hasCredentials: boolean,
isConfigDefinedCustomProvider: boolean,
): boolean => sourcesLoaded && !hasCredentials && !isConfigDefinedCustomProvider;
@@ -1,3 +1,4 @@
import { rankByQuery } from '@/lib/search/fuzzySearch';
import React from 'react';
import QRCode from 'qrcode';
import { Button } from '@/components/ui/button';
@@ -23,7 +24,9 @@ import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLay
import {
SettingsSection,
SettingsGroupTitle,
SettingsChipGroup,
SETTINGS_PAGE_TITLE_CLASS,
SETTINGS_SECTION_TITLE_CLASS,
SETTINGS_FIELD_LABEL_CLASS,
SETTINGS_SELECT_SIZE,
} from '@/components/sections/shared/SettingsSection';
@@ -150,6 +153,58 @@ const isConnectingPhase = (phase?: string): boolean => {
return Boolean(phase && CONNECTING_PHASES.has(phase));
};
// The backend reports 13 lifecycle phases. People only need to know which of
// three situations they are in; the phase stays as the secondary detail line.
type InstanceState = 'idle' | 'connecting' | 'ready' | 'error';
const instanceState = (phase?: string): InstanceState => {
if (phase === 'ready') return 'ready';
if (phase === 'error') return 'error';
if (phase === 'degraded' || isConnectingPhase(phase)) return 'connecting';
return 'idle';
};
const instanceStateLabelKey = (state: InstanceState): I18nKey => {
switch (state) {
case 'ready':
return 'settings.remoteInstances.page.state.ready';
case 'connecting':
return 'settings.remoteInstances.page.state.connecting';
case 'error':
return 'settings.remoteInstances.page.state.problem';
default:
return 'settings.remoteInstances.page.state.notConnected';
}
};
// Known backend failures that the user can act on from here. Everything else
// falls back to the raw detail plus the logs button.
type ErrorRemedy = 'uiPassword' | 'localPort' | 'noRuntime' | 'noOpencode' | 'externalPort' | null;
const errorRemedy = (detail?: string): ErrorRemedy => {
const text = (detail || '').toLowerCase();
if (!text) return null;
if (text.includes('ui authentication') || text.includes('ui password')) return 'uiPassword';
if (text.includes('already in use') || text.includes('eaddrinuse')) return 'localPort';
if (text.includes('neither bun nor npm')) return 'noRuntime';
if (text.includes('opencode cli is not installed')) return 'noOpencode';
if (text.includes('requires a ui password')) return 'uiPassword';
if (text.includes('preferred remote openchamber port')) return 'externalPort';
return null;
};
// Remedies the user resolves on the remote machine: explain, do not offer a button.
const REMEDY_HINT_KEYS = {
noRuntime: 'settings.remoteInstances.page.error.hint.noRuntime',
noOpencode: 'settings.remoteInstances.page.error.hint.noOpencode',
} satisfies Record<string, I18nKey>;
const remedyHintKey = (remedy: ErrorRemedy): I18nKey | null => {
if (remedy === 'noRuntime') return REMEDY_HINT_KEYS.noRuntime;
if (remedy === 'noOpencode') return REMEDY_HINT_KEYS.noOpencode;
return null;
};
const phaseDotClass = (phase?: string): string => {
if (phase === 'ready') {
return 'bg-[var(--status-success)] animate-pulse';
@@ -462,8 +517,11 @@ export const RemoteInstancesPage: React.FC = () => {
const [transportOptions, setTransportOptions] = React.useState<{ localUrl: string | null; lanUrl: string | null; relayAvailable: boolean } | null>(null);
const revokedClientCount = React.useMemo(() => remoteClients.filter((client) => Boolean(client.revokedAt)).length, [remoteClients]);
const [sshAddDialogOpen, setSshAddDialogOpen] = React.useState(false);
const [sshCommandDraft, setSshCommandDraft] = React.useState('ssh user@example.com');
const [sshAddMode, setSshAddMode] = React.useState<'saved' | 'manual'>('saved');
const [sshHostSearch, setSshHostSearch] = React.useState('');
const [sshCommandDraft, setSshCommandDraft] = React.useState('');
const [sshNameDraft, setSshNameDraft] = React.useState('');
const [advancedOpen, setAdvancedOpen] = React.useState(false);
React.useEffect(() => {
void load();
@@ -730,7 +788,7 @@ export const RemoteInstancesPage: React.FC = () => {
await createFromCommand(id, command, sshNameDraft.trim() || t('settings.remoteInstances.sidebar.newSshInstanceName'));
setSelectedId(id);
setSshAddDialogOpen(false);
setSshCommandDraft('ssh user@example.com');
setSshCommandDraft('');
setSshNameDraft('');
toast.success(t('settings.remoteInstances.page.toast.instanceCreated'));
} catch (error) {
@@ -740,6 +798,12 @@ export const RemoteInstancesPage: React.FC = () => {
}
}, [createFromCommand, setSelectedId, sshCommandDraft, sshNameDraft, t]);
const openSshAddDialog = React.useCallback(() => {
setSshHostSearch('');
setSshAddMode(importCandidates.length > 0 ? 'saved' : 'manual');
setSshAddDialogOpen(true);
}, [importCandidates.length]);
const setDefaultDirectHost = React.useCallback(async (id: string) => {
await persistDirectHosts(directHosts, id);
}, [directHosts, persistDirectHosts]);
@@ -1000,6 +1064,11 @@ export const RemoteInstancesPage: React.FC = () => {
setDraft(selectedInstance);
}, [selectedInstance]);
// Every instance opens on the simple view; advanced stays a deliberate choice.
React.useEffect(() => {
setAdvancedOpen(false);
}, [selectedId]);
React.useEffect(() => {
if (!selectedId) {
return;
@@ -1064,6 +1133,9 @@ export const RemoteInstancesPage: React.FC = () => {
const canDisconnect = isReady || isBusy;
const statusAgeMs = status ? Math.max(0, clockMs - status.updatedAtMs) : 0;
const reconnectAppearsStuck = isReconnecting && statusAgeMs > 12_000;
const currentState = instanceState(statusPhase);
const currentRemedy = currentState === 'error' ? errorRemedy(status?.detail) : null;
const currentRemedyHintKey = remedyHintKey(currentRemedy);
const hasChanges = React.useMemo(() => {
if (!draft || !selectedInstance) return false;
@@ -1083,6 +1155,25 @@ export const RemoteInstancesPage: React.FC = () => {
return;
}
// "Already running" cannot pick a port on its own; catching it here keeps
// the failure in the form instead of surfacing it mid-connect.
if (normalized.remoteOpenchamber.mode === 'external' && !normalized.remoteOpenchamber.preferredPort) {
toast.error(t('settings.remoteInstances.page.validation.externalPortRequired'));
setAdvancedOpen(true);
return;
}
if (
normalized.remoteOpenchamber.mode === 'managed' &&
normalized.remoteOpenchamber.bindHost === '0.0.0.0' &&
!normalized.auth.openchamberPassword?.value?.trim()
) {
toast.error(t('settings.remoteInstances.page.validation.remoteLanNeedsPassword'));
setAdvancedOpen(true);
window.setTimeout(() => uiPasswordRef.current?.focus(), 0);
return;
}
if (normalized.localForward.bindHost === '0.0.0.0') {
const allow = window.confirm(
t('settings.remoteInstances.page.confirm.bindAllInterfaces'),
@@ -1154,6 +1245,7 @@ export const RemoteInstancesPage: React.FC = () => {
const handleImportCandidate = React.useCallback(
(host: string, pattern: boolean) => {
setSshAddDialogOpen(false);
if (pattern) {
setPatternHost(host);
setPatternDestination(suggestConcreteHost(host));
@@ -1164,6 +1256,22 @@ export const RemoteInstancesPage: React.FC = () => {
[createImportedInstance],
);
const filteredImportCandidates = React.useMemo(
() => rankByQuery(importCandidates, sshHostSearch, (candidate) => [candidate.host, candidate.sshCommand]),
[importCandidates, sshHostSearch],
);
// Opening a ready instance means pointing this window at the forwarded local
// URL — the same navigation the host switcher performs after its own connect.
const openInstanceUrl = React.useCallback((localUrl?: string) => {
const target = (localUrl || '').trim();
if (!target) {
toast.error(t('settings.remoteInstances.page.toast.instanceUrlUnavailable'));
return;
}
navigateToUrl(target);
}, [t]);
const handlePatternCreate = React.useCallback(async () => {
const host = patternHost;
const destination = patternDestination.trim();
@@ -1216,6 +1324,44 @@ export const RemoteInstancesPage: React.FC = () => {
}
}, [connect, selectedInstance, t, upsertInstance]);
const uiPasswordRef = React.useRef<HTMLInputElement | null>(null);
const remotePortRef = React.useRef<HTMLDivElement | null>(null);
// Turn a reported failure into the one action that resolves it, instead of
// leaving the raw backend sentence as the whole answer.
const applyErrorRemedy = React.useCallback(async (remedy: ErrorRemedy) => {
if (!selectedInstance) return;
if (remedy === 'localPort') {
const nextInstance: DesktopSshInstance = {
...selectedInstance,
localForward: {
...selectedInstance.localForward,
preferredLocalPort: randomPort(),
},
};
try {
await upsertInstance(nextInstance);
await connect(nextInstance.id);
toast.success(t('settings.remoteInstances.sidebar.toast.retriedWithRandomPort'));
} catch (error) {
toast.error(t('settings.remoteInstances.page.toast.connectFailed'), {
description: error instanceof Error ? error.message : String(error),
});
}
return;
}
setAdvancedOpen(true);
window.setTimeout(() => {
if (remedy === 'uiPassword') {
uiPasswordRef.current?.focus();
return;
}
remotePortRef.current?.scrollIntoView({ block: 'center' });
}, 0);
}, [connect, selectedInstance, t, upsertInstance]);
const readLogsForInstance = React.useCallback(async (id: string) => {
const lines = await desktopSshLogs(id, 600);
return lines.map((line) => formatLogLine(line));
@@ -1321,6 +1467,24 @@ export const RemoteInstancesPage: React.FC = () => {
return;
}
if (!canDisconnect && draft.remoteOpenchamber.mode === 'external' && !draft.remoteOpenchamber.preferredPort) {
toast.error(t('settings.remoteInstances.page.validation.externalPortRequired'));
setAdvancedOpen(true);
return;
}
if (
!canDisconnect &&
draft.remoteOpenchamber.mode === 'managed' &&
draft.remoteOpenchamber.bindHost === '0.0.0.0' &&
!draft.auth.openchamberPassword?.value?.trim()
) {
toast.error(t('settings.remoteInstances.page.validation.remoteLanNeedsPassword'));
setAdvancedOpen(true);
window.setTimeout(() => uiPasswordRef.current?.focus(), 0);
return;
}
setIsPrimaryActionPending(true);
const operation = canDisconnect ? disconnect(draft.id) : connectWithPortRecovery();
void operation
@@ -1774,7 +1938,7 @@ export const RemoteInstancesPage: React.FC = () => {
title={t('settings.remoteInstances.sidebar.title')}
description={t('settings.remoteInstances.sidebar.total', { count: instances.length })}
headerAction={(
<Button type="button" size="xs" className="!font-normal" onClick={() => setSshAddDialogOpen(true)}>
<Button type="button" size="xs" className="!font-normal" onClick={openSshAddDialog}>
<Icon name="add" className="h-3.5 w-3.5" />
{t('settings.remoteInstances.sidebar.actions.addSshInstance')}
</Button>
@@ -1782,50 +1946,71 @@ export const RemoteInstancesPage: React.FC = () => {
contentClassName="space-y-2.5"
>
{isLoading ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.state.loadingInstances')}</p>
) : instances.length === 0 ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.noneFound')}</p>
<p className="typography-meta text-muted-foreground">
{importCandidates.length === 1
? t('settings.remoteInstances.page.empty.noInstancesWithOneImport')
: importCandidates.length > 1
? t('settings.remoteInstances.page.empty.noInstancesWithImports', { count: importCandidates.length })
: t('settings.remoteInstances.page.empty.noInstances')}
</p>
) : instances.map((instance) => {
const instanceStatus = statusesById[instance.id];
const title = instance.nickname?.trim() || instance.sshParsed?.destination || instance.id;
const phase = instanceStatus?.phase;
const ready = phase === 'ready';
const state = instanceState(phase);
const failureDetail = state === 'error' ? instanceStatus?.detail : undefined;
return (
<div key={instance.id} className="flex items-center justify-between gap-3 py-1.5">
<div className="min-w-0 space-y-0.5">
<div className="flex min-w-0 items-center gap-2">
<span className={`h-2 w-2 rounded-full ${phaseDotClass(phase)}`} />
<p className="typography-ui-label text-foreground truncate">{title}</p>
<div key={instance.id} className="space-y-1.5 py-1.5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 space-y-0.5">
<div className="flex min-w-0 items-center gap-2">
<span className={`h-2 w-2 rounded-full ${phaseDotClass(phase)}`} />
<p className="typography-ui-label text-foreground truncate">{title}</p>
</div>
<p className="typography-micro text-muted-foreground truncate">
{t(instanceStateLabelKey(state))}
{state === 'connecting' ? ` · ${t(phaseLabelKey(phase))}` : ''}
{ready && instanceStatus?.localUrl ? ` · ${instanceStatus.localUrl}` : ''}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{ready ? (
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => openInstanceUrl(instanceStatus?.localUrl)}>
<Icon name="external-link" className="h-3.5 w-3.5" />
{t('settings.remoteInstances.page.actions.open')}
</Button>
) : null}
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => {
const op = ready ? disconnect(instance.id) : connect(instance.id);
void op.catch((err) => toast.error(ready ? t('settings.remoteInstances.sidebar.toast.disconnectFailed') : t('settings.remoteInstances.sidebar.toast.connectFailed'), {
description: err instanceof Error ? err.message : String(err),
}));
}}>
{ready ? <Icon name="stop" className="h-3.5 w-3.5" /> : <Icon name="plug-2" className="h-3.5 w-3.5" />}
{ready ? t('settings.remoteInstances.sidebar.actions.disconnect') : t('settings.remoteInstances.sidebar.actions.connect')}
</Button>
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => setSelectedId(instance.id)}>
<Icon name="pencil" className="h-3.5 w-3.5" />
{t('desktopHostSwitcher.actions.edit')}
</Button>
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => {
const ok = window.confirm(t('settings.remoteInstances.page.confirm.removeInstance'));
if (!ok) return;
void removeInstance(instance.id).catch((err) => toast.error(t('settings.remoteInstances.page.toast.removeInstanceFailed'), {
description: err instanceof Error ? err.message : String(err),
}));
}}>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
{t('settings.common.actions.delete')}
</Button>
</div>
<p className="typography-micro text-muted-foreground truncate">
{t(phaseLabelKey(phase))}{instanceStatus?.localUrl ? ` · ${instanceStatus.localUrl}` : ''}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => {
const op = ready ? disconnect(instance.id) : connect(instance.id);
void op.catch((err) => toast.error(ready ? t('settings.remoteInstances.sidebar.toast.disconnectFailed') : t('settings.remoteInstances.sidebar.toast.connectFailed'), {
description: err instanceof Error ? err.message : String(err),
}));
}}>
{ready ? <Icon name="stop" className="h-3.5 w-3.5" /> : <Icon name="plug-2" className="h-3.5 w-3.5" />}
{ready ? t('settings.remoteInstances.sidebar.actions.disconnect') : t('settings.remoteInstances.sidebar.actions.connect')}
</Button>
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => setSelectedId(instance.id)}>
<Icon name="pencil" className="h-3.5 w-3.5" />
{t('desktopHostSwitcher.actions.edit')}
</Button>
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => {
const ok = window.confirm(t('settings.remoteInstances.page.confirm.removeInstance'));
if (!ok) return;
void removeInstance(instance.id).catch((err) => toast.error(t('settings.remoteInstances.page.toast.removeInstanceFailed'), {
description: err instanceof Error ? err.message : String(err),
}));
}}>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
{t('settings.common.actions.delete')}
</Button>
</div>
{failureDetail ? (
<p className="typography-micro text-[var(--status-error)] break-words">{failureDetail}</p>
) : null}
</div>
);
})}
@@ -1835,52 +2020,70 @@ export const RemoteInstancesPage: React.FC = () => {
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t('settings.remoteInstances.sidebar.actions.addSshInstance')}</DialogTitle>
<DialogDescription>{t('settings.remoteInstances.page.section.instanceDescription')}</DialogDescription>
<DialogDescription>{t('settings.remoteInstances.page.addDialog.description')}</DialogDescription>
</DialogHeader>
<form className="space-y-3" onSubmit={(event) => { event.preventDefault(); void createSshInstanceFromDialog(); }}>
<Input className="h-8" value={sshNameDraft} onChange={(event) => setSshNameDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.nicknamePlaceholder')} disabled={isSaving} />
<Input className="h-8" value={sshCommandDraft} onChange={(event) => setSshCommandDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.sshCommandPlaceholder')} disabled={isSaving} autoFocus />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setSshAddDialogOpen(false)} disabled={isSaving}>{t('settings.common.actions.cancel')}</Button>
<Button type="submit" size="xs" className="!font-normal" disabled={isSaving || !sshCommandDraft.trim()}>{t('settings.common.actions.create')}</Button>
<SettingsChipGroup
value={sshAddMode}
onChange={setSshAddMode}
aria-label={t('settings.remoteInstances.page.addDialog.sourceLabel')}
options={[
{ value: 'saved', label: t('settings.remoteInstances.page.addDialog.tab.saved') },
{ value: 'manual', label: t('settings.remoteInstances.page.addDialog.tab.manual') },
]}
/>
{sshAddMode === 'saved' ? (
<div className="space-y-2">
<Input
className="h-8"
value={sshHostSearch}
onChange={(event) => setSshHostSearch(event.target.value)}
placeholder={t('settings.remoteInstances.page.addDialog.searchPlaceholder')}
autoFocus
/>
{isImportsLoading ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
) : importCandidates.length === 0 ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.addDialog.emptySaved')}</p>
) : filteredImportCandidates.length === 0 ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.addDialog.searchEmpty')}</p>
) : (
<div className="max-h-[45vh] overflow-auto">
{filteredImportCandidates.map((candidate) => (
<div key={`${candidate.source}:${candidate.host}`} className="flex items-center justify-between gap-3 border-b border-[var(--surface-subtle)] py-2.5 last:border-b-0">
<div className="min-w-0">
<div className="typography-ui-label font-medium text-foreground truncate">
{candidate.host}
{candidate.pattern ? ` ${t('settings.remoteInstances.page.import.patternSuffix')}` : ''}
</div>
<div className="typography-meta text-muted-foreground truncate">{candidate.sshCommand}</div>
</div>
<Button
type="button"
variant="outline"
size="xs"
className="!font-normal"
onClick={() => void handleImportCandidate(candidate.host, candidate.pattern)}
>
{t('settings.remoteInstances.page.addDialog.use')}
</Button>
</div>
))}
</div>
)}
</div>
</form>
) : (
<form className="space-y-3" onSubmit={(event) => { event.preventDefault(); void createSshInstanceFromDialog(); }}>
<Input className="h-8" value={sshNameDraft} onChange={(event) => setSshNameDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.nicknamePlaceholder')} disabled={isSaving} />
<Input className="h-8" value={sshCommandDraft} onChange={(event) => setSshCommandDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.sshCommandPlaceholder')} disabled={isSaving} autoFocus />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setSshAddDialogOpen(false)} disabled={isSaving}>{t('settings.common.actions.cancel')}</Button>
<Button type="submit" size="xs" className="!font-normal" disabled={isSaving || !sshCommandDraft.trim()}>{t('settings.common.actions.create')}</Button>
</div>
</form>
)}
</DialogContent>
</Dialog> : null}
{showInstanceManagement ? <SettingsSection
title={t('settings.remoteInstances.page.import.sectionTitle')}
>
{isImportsLoading ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
) : importCandidates.length === 0 ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.noneFound')}</p>
) : (
<div>
{importCandidates.map((candidate) => (
<div key={`${candidate.source}:${candidate.host}`} className="flex items-center justify-between gap-3 border-b border-[var(--surface-subtle)] py-3 last:border-b-0">
<div className="min-w-0">
<div className="typography-ui-label font-medium text-foreground truncate">
{candidate.host}
{candidate.pattern ? ` ${t('settings.remoteInstances.page.import.patternSuffix')}` : ''}
</div>
<div className="typography-meta text-muted-foreground truncate">{candidate.sshCommand}</div>
</div>
<Button
type="button"
variant="outline"
size="xs"
className="!font-normal"
onClick={() => void handleImportCandidate(candidate.host, candidate.pattern)}
>
{t('settings.common.actions.import')}
</Button>
</div>
))}
</div>
)}
</SettingsSection> : null}
<Dialog
open={Boolean(patternHost)}
onOpenChange={(open) => {
@@ -1925,6 +2128,10 @@ export const RemoteInstancesPage: React.FC = () => {
}
const isManagedMode = draft.remoteOpenchamber.mode === 'managed';
// Publishing the remote server to its network turns the UI password from an
// option into the only thing standing in front of it.
const remoteLanExposed = isManagedMode && draft.remoteOpenchamber.bindHost === '0.0.0.0';
const uiPasswordMissing = remoteLanExposed && !draft.auth.openchamberPassword?.value?.trim();
const instanceTitle = draft.nickname?.trim() || draft.sshParsed?.destination || draft.id;
return (
@@ -1934,7 +2141,8 @@ export const RemoteInstancesPage: React.FC = () => {
<h1 className={`${SETTINGS_PAGE_TITLE_CLASS} truncate`}>{instanceTitle}</h1>
<div className="mt-1 flex flex-wrap items-center gap-2 typography-meta text-muted-foreground">
<span className={`h-2.5 w-2.5 rounded-full ${phaseDotClass(statusPhase)}`} />
<span>{t(phaseLabelKey(statusPhase))}</span>
<span className="text-foreground">{t(instanceStateLabelKey(currentState))}</span>
{currentState === 'connecting' ? <span>{t(phaseLabelKey(statusPhase))}</span> : null}
{status?.localUrl ? <span className="font-mono text-foreground/80">{status.localUrl}</span> : null}
{reconnectAppearsStuck ? <span>{t('settings.remoteInstances.page.status.reconnectStale')}</span> : null}
</div>
@@ -2005,6 +2213,29 @@ export const RemoteInstancesPage: React.FC = () => {
{t('settings.remoteInstances.sidebar.actions.remove')}
</Button>
</div>
{currentState === 'error' && status?.detail ? (
<div className="space-y-2 rounded-md border border-[var(--status-error)]/30 bg-[var(--status-error-background)] p-3">
<p className="typography-meta text-[var(--status-error)] break-words">{status.detail}</p>
{currentRemedyHintKey ? (
<p className="typography-micro text-muted-foreground">{t(currentRemedyHintKey)}</p>
) : null}
{currentRemedy && !currentRemedyHintKey ? (
<Button
type="button"
variant="outline"
size="xs"
className="!font-normal"
onClick={() => void applyErrorRemedy(currentRemedy)}
>
{currentRemedy === 'uiPassword'
? t('settings.remoteInstances.page.error.action.setUiPassword')
: currentRemedy === 'localPort'
? t('settings.remoteInstances.page.error.action.pickRandomPort')
: t('settings.remoteInstances.page.error.action.setRemotePort')}
</Button>
) : null}
</div>
) : null}
{status?.localUrl ? (
<div className="flex flex-wrap items-center gap-2 typography-meta text-muted-foreground">
<span>{t('settings.remoteInstances.page.status.currentLocalUrl')}</span>
@@ -2046,30 +2277,6 @@ export const RemoteInstancesPage: React.FC = () => {
placeholder={t('settings.remoteInstances.page.field.nicknamePlaceholder')}
/>
</div>
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.connectionTimeoutSeconds')}</span>
<NumberInput
containerClassName="w-fit"
min={5}
max={240}
step={1}
className="w-16 tabular-nums"
value={draft.connectionTimeoutSec}
onValueChange={(next) => {
updateDraft((current) => ({
...current,
connectionTimeoutSec: Number.isFinite(next) ? next : current.connectionTimeoutSec,
}));
}}
/>
</div>
</SettingsSection>
<SettingsSection
title={t('settings.remoteInstances.page.section.remoteServer')}
info={t('settings.remoteInstances.page.section.remoteServerDescription')}
contentClassName="space-y-3"
>
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<div className="w-56 shrink-0">
<HintLabel
@@ -2099,8 +2306,40 @@ export const RemoteInstancesPage: React.FC = () => {
</Select>
</div>
</SettingsSection>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger className="mt-6 w-auto justify-start gap-1.5">
<span className={SETTINGS_SECTION_TITLE_CLASS}>{t('settings.remoteInstances.page.section.advanced')}</span>
<Icon name={advancedOpen ? 'arrow-up-s' : 'arrow-down-s'} className="h-4 w-4 text-muted-foreground" />
</CollapsibleTrigger>
<CollapsibleContent>
<p className="px-2 pb-2 typography-micro text-muted-foreground">{t('settings.remoteInstances.page.section.advancedHint')}</p>
<div className="flex flex-col gap-1.5 px-2 py-1.5 md:flex-row md:items-center md:gap-8">
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.connectionTimeoutSeconds')}</span>
<NumberInput
containerClassName="w-fit"
min={5}
max={240}
step={1}
className="w-20 tabular-nums"
value={draft.connectionTimeoutSec}
onValueChange={(next) => {
updateDraft((current) => ({
...current,
connectionTimeoutSec: Number.isFinite(next) ? next : current.connectionTimeoutSec,
}));
}}
/>
</div>
<SettingsSection
title={t('settings.remoteInstances.page.section.remoteServer')}
info={t('settings.remoteInstances.page.section.remoteServerDescription')}
contentClassName="space-y-3"
>
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<div className="w-56 shrink-0">
<div className="w-56 shrink-0" ref={remotePortRef}>
<HintLabel
label={t('settings.remoteInstances.page.field.preferredRemotePort')}
hint={t('settings.remoteInstances.page.field.preferredRemotePortHint')}
@@ -2111,7 +2350,7 @@ export const RemoteInstancesPage: React.FC = () => {
min={1}
max={65535}
step={1}
className="w-20 tabular-nums"
className="w-32 tabular-nums"
value={draft.remoteOpenchamber.preferredPort}
onValueChange={(next) => {
updateDraft((current) => ({
@@ -2150,10 +2389,7 @@ export const RemoteInstancesPage: React.FC = () => {
...current,
remoteOpenchamber: {
...current.remoteOpenchamber,
installMethod:
value === 'npm' || value === 'download_release' || value === 'upload_bundle'
? value
: 'bun',
installMethod: value === 'npm' || value === 'bun' ? value : 'auto',
},
}))
}
@@ -2162,15 +2398,45 @@ export const RemoteInstancesPage: React.FC = () => {
<SelectValue placeholder={t('settings.remoteInstances.page.field.selectInstallMethodPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t('settings.remoteInstances.page.field.installMethodAuto')}</SelectItem>
<SelectItem value="bun">bun</SelectItem>
<SelectItem value="npm">npm</SelectItem>
<SelectItem value="download_release">{t('settings.remoteInstances.page.field.installMethodDownloadRelease')}</SelectItem>
<SelectItem value="upload_bundle">{t('settings.remoteInstances.page.field.installMethodUploadBundle')}</SelectItem>
</SelectContent>
</Select>
</div>
) : null}
{isManagedMode ? (
<div className="py-1.5">
<div className="flex flex-col gap-1.5 md:flex-row md:items-center md:gap-8">
<div className="w-56 shrink-0">
<HintLabel
label={t('settings.remoteInstances.page.field.remoteLanAccess')}
hint={t('settings.remoteInstances.page.field.remoteLanAccessHint')}
/>
</div>
<Switch
checked={remoteLanExposed}
onCheckedChange={(checked) =>
updateDraft((current) => ({
...current,
remoteOpenchamber: {
...current.remoteOpenchamber,
bindHost: checked ? '0.0.0.0' : '127.0.0.1',
},
}))
}
aria-label={t('settings.remoteInstances.page.field.remoteLanAccess')}
/>
</div>
{remoteLanExposed ? (
<p className="mt-2 typography-micro text-[var(--status-warning)] md:pl-[16rem]">
{t('settings.remoteInstances.page.field.remoteLanAccessWarning')}
</p>
) : null}
</div>
) : null}
{isManagedMode ? (
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<div className="w-56 shrink-0">
@@ -2227,13 +2493,13 @@ export const RemoteInstancesPage: React.FC = () => {
}));
}}
>
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="w-fit min-w-[140px]">
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="w-fit min-w-[240px]">
<SelectValue placeholder={t('settings.remoteInstances.page.field.selectBindHostPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="127.0.0.1">127.0.0.1</SelectItem>
<SelectItem value="localhost">localhost</SelectItem>
<SelectItem value="0.0.0.0">0.0.0.0</SelectItem>
<SelectItem value="127.0.0.1">{t('settings.remoteInstances.page.field.bindHostOption.loopback')}</SelectItem>
<SelectItem value="localhost">{t('settings.remoteInstances.page.field.bindHostOption.localhost')}</SelectItem>
<SelectItem value="0.0.0.0">{t('settings.remoteInstances.page.field.bindHostOption.lan')}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -2251,7 +2517,7 @@ export const RemoteInstancesPage: React.FC = () => {
min={1}
max={65535}
step={1}
className="w-20 tabular-nums"
className="w-32 tabular-nums"
value={draft.localForward.preferredLocalPort}
onValueChange={(next) => {
updateDraft((current) => ({
@@ -2293,6 +2559,13 @@ export const RemoteInstancesPage: React.FC = () => {
</Button>
</div>
</div>
<div className="space-y-1 pt-1">
<p className="typography-micro text-muted-foreground">{t('settings.remoteInstances.page.tunnelPreview.caption')}</p>
<p className="typography-micro font-mono text-foreground/80 break-all">
{`${draft.localForward.bindHost}:${draft.localForward.preferredLocalPort || 'auto'}${draft.sshParsed?.destination || draft.nickname || 'remote'}:${draft.remoteOpenchamber.preferredPort || 'auto'}`}
</p>
</div>
</SettingsSection>
<SettingsSection
@@ -2301,7 +2574,12 @@ export const RemoteInstancesPage: React.FC = () => {
contentClassName="space-y-3"
>
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.sshPasswordOptional')}</span>
<div className="w-56 shrink-0">
<HintLabel
label={t('settings.remoteInstances.page.field.sshPasswordOptional')}
hint={t('settings.remoteInstances.page.field.sshPasswordHint')}
/>
</div>
<Input
className="h-7 md:max-w-sm"
type="password"
@@ -2324,10 +2602,21 @@ export const RemoteInstancesPage: React.FC = () => {
</div>
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.uiPasswordOptional')}</span>
<div className="w-56 shrink-0">
<HintLabel
label={remoteLanExposed
? t('settings.remoteInstances.page.field.uiPasswordRequired')
: t('settings.remoteInstances.page.field.uiPasswordOptional')}
hint={isManagedMode
? t('settings.remoteInstances.page.field.uiPasswordHintManaged')
: t('settings.remoteInstances.page.field.uiPasswordHintExternal')}
/>
</div>
<Input
className="h-7 md:max-w-sm"
className={cn('h-7 md:max-w-sm', uiPasswordMissing && 'border-[var(--status-error)]')}
type="password"
ref={uiPasswordRef}
aria-invalid={uiPasswordMissing}
value={draft.auth.openchamberPassword?.value || ''}
onChange={(event) =>
updateDraft((current) => ({
@@ -2345,6 +2634,11 @@ export const RemoteInstancesPage: React.FC = () => {
placeholder={t('settings.remoteInstances.page.field.uiPasswordPlaceholder')}
/>
</div>
{uiPasswordMissing ? (
<p className="typography-micro text-[var(--status-error)] md:pl-[16rem]">
{t('settings.remoteInstances.page.field.uiPasswordMissingForLan')}
</p>
) : null}
</SettingsSection>
<SettingsSection
@@ -2481,7 +2775,7 @@ export const RemoteInstancesPage: React.FC = () => {
min={1}
max={65535}
step={1}
className="w-16 tabular-nums"
className="w-32 tabular-nums"
value={forward.localPort}
onValueChange={(next) => {
updateForward((item) => ({
@@ -2523,7 +2817,7 @@ export const RemoteInstancesPage: React.FC = () => {
min={1}
max={65535}
step={1}
className="w-16 tabular-nums"
className="w-32 tabular-nums"
value={forward.remotePort}
onValueChange={(next) => {
updateForward((item) => ({
@@ -2621,6 +2915,9 @@ export const RemoteInstancesPage: React.FC = () => {
</Button>
</SettingsSection>
</CollapsibleContent>
</Collapsible>
<div className="mt-8 border-t border-[var(--interactive-border)] pt-3">
<div className="flex items-center gap-2">
<Button type="button" size="xs" className="!font-normal" onClick={() => void handleSave()} disabled={!hasChanges || isSaving}>
@@ -64,18 +64,24 @@ export const SettingsPageLayout: React.FC<SettingsPageLayoutProps> = ({
)}
>
{hasHeader && (
<div className="mb-2 flex items-start justify-between gap-4 pb-6">
<div className="min-w-0 space-y-1">
// Wraps rather than squeezes. The action cluster never shrinks, so on
// a narrow pane it used to starve the title until the name was a
// single letter and an ellipsis; giving the title block a basis lets
// the actions drop to their own line instead.
<div className="mb-2 flex flex-wrap items-start justify-between gap-x-4 gap-y-3 pb-6">
<div className="min-w-0 flex-1 basis-64 space-y-1">
{title != null ? (
isPlainTitle ? (
hasTitleChrome ? (
<div className="flex min-w-0 items-center gap-2">
{titleLeading}
<h1 className={cn(SETTINGS_PAGE_TITLE_CLASS, 'min-w-0 truncate')}>{title}</h1>
{titleAccessory}
<h1 data-settings-page-heading tabIndex={-1} className={cn(SETTINGS_PAGE_TITLE_CLASS, 'min-w-0 truncate')}>{title}</h1>
{/* A status badge carries a fixed word; compressing it
wraps the text inside its own pill. */}
<span className="shrink-0">{titleAccessory}</span>
</div>
) : (
<h1 className={SETTINGS_PAGE_TITLE_CLASS}>{title}</h1>
<h1 data-settings-page-heading tabIndex={-1} className={SETTINGS_PAGE_TITLE_CLASS}>{title}</h1>
)
) : (
title
@@ -89,7 +95,7 @@ export const SettingsPageLayout: React.FC<SettingsPageLayoutProps> = ({
)
) : null}
</div>
<div className="flex shrink-0 items-center gap-3">
<div className="flex shrink-0 flex-wrap items-center justify-end gap-3">
{headerEnd}
{showSaveStatus && <SettingsSaveStatus />}
</div>
@@ -8,19 +8,22 @@ import {
} from '@/components/ui/dropdown-menu';
import { Icon } from "@/components/icon/Icon";
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { isVSCodeRuntime } from '@/lib/desktop';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
const formatProjectLabel = (label: string): string => {
return label.replace(/[-_]/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase());
};
const formatProjectLabel = (label: string): string => label.trim();
export const SettingsProjectSelector: React.FC<{ className?: string }> = ({ className }) => {
const { t } = useI18n();
const projects = useProjectsStore((state) => state.projects);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const setActiveProject = useProjectsStore((state) => state.setActiveProject);
// Settings-only selection. Picking a project here used to call
// `setActiveProject`, which relocates the chat, the session list and the file
// tree; reading another project's configuration must not move the app.
const settingsDirectory = useSettingsDirectory();
const setSettingsProjectPath = useUIStore((state) => state.setSettingsProjectPath);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
@@ -32,8 +35,8 @@ export const SettingsProjectSelector: React.FC<{ className?: string }> = ({ clas
if (sortedProjects.length === 0) {
return null;
}
return sortedProjects.find((p) => p.id === activeProjectId) ?? sortedProjects[0];
}, [activeProjectId, sortedProjects]);
return sortedProjects.find((p) => p.path === settingsDirectory) ?? sortedProjects[0];
}, [settingsDirectory, sortedProjects]);
if (isVSCode || sortedProjects.length === 0) {
return null;
@@ -69,7 +72,9 @@ export const SettingsProjectSelector: React.FC<{ className?: string }> = ({ clas
value={activeProject?.id ?? ''}
onValueChange={(value) => {
if (!value) return;
setActiveProject(value);
const project = sortedProjects.find((entry) => entry.id === value);
if (!project) return;
setSettingsProjectPath(project.path);
}}
>
{sortedProjects.map((project) => {
@@ -59,7 +59,7 @@ export const SETTINGS_SECTION_TITLE_CLASS =
/** Split-pane sidebar panel title — same level as section titles. */
export const SETTINGS_PANEL_TITLE_CLASS = SETTINGS_SECTION_TITLE_CLASS;
/** L3 — control-group heading inside a section. */
export const SETTINGS_GROUP_TITLE_CLASS =
const SETTINGS_GROUP_TITLE_CLASS =
'typography-settings-group-title text-foreground';
/** L4 — field / control labels. */
export const SETTINGS_FIELD_LABEL_CLASS =
@@ -310,8 +310,8 @@ export const SettingsFieldRow: React.FC<SettingsFieldRowProps> = ({
)}
>
<div className="min-w-0 @xl:w-56 @xl:shrink-0">
<div className="flex items-center gap-1.5">
<div className={SETTINGS_FIELD_LABEL_CLASS}>{label}</div>
<div className="flex min-w-0 items-center gap-1.5">
<div className={cn('min-w-0 truncate', SETTINGS_FIELD_LABEL_CLASS)}>{label}</div>
{info != null ? <SettingsInfoHint>{info}</SettingsInfoHint> : null}
</div>
{description != null ? (
@@ -5,7 +5,9 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
import { toast } from '@/components/ui';
import { useSkillsStore, type SkillConfig, type SkillScope, type SupportingFile, type PendingFile } from '@/stores/useSkillsStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { selectSkillsForDirectory, useSkillsStore, type SkillConfig, type SkillScope, type SupportingFile, type PendingFile } from '@/stores/useSkillsStore';
import { usePendingOpenCodeRestartStore } from '@/stores/usePendingOpenCodeRestartStore';
import { useShallow } from 'zustand/react/shallow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
@@ -118,7 +120,6 @@ const SkillsInstalledPage: React.FC = () => {
getSkillDetail,
createSkill,
updateSkill,
skills,
skillDraft,
setSkillDraft,
setSelectedSkill,
@@ -128,13 +129,16 @@ const SkillsInstalledPage: React.FC = () => {
getSkillDetail: s.getSkillDetail,
createSkill: s.createSkill,
updateSkill: s.updateSkill,
skills: s.skills,
skillDraft: s.skillDraft,
setSkillDraft: s.setSkillDraft,
setSelectedSkill: s.setSelectedSkill,
})));
const selectedSkill = selectedSkillName ? getSkillByName(selectedSkillName) : null;
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const skills = useSkillsStore((state) => selectSkillsForDirectory(state, settingsDirectory));
const selectedSkill = selectedSkillName ? getSkillByName(selectedSkillName, settingsDirectory) : null;
const isNewSkill = Boolean(skillDraft && skillDraft.name === selectedSkillName && !selectedSkill);
const hasStaleSelection = Boolean(selectedSkillName && !selectedSkill && !skillDraft);
const isReadOnlySkill = selectedSkill?.path === '<built-in>';
@@ -231,7 +235,7 @@ const SkillsInstalledPage: React.FC = () => {
} else if (selectedSkillName && selectedSkill) {
setIsLoading(true);
try {
const detail = await getSkillDetail(selectedSkillName);
const detail = await getSkillDetail(selectedSkillName, settingsDirectory);
if (detail) {
const md = detail.sources.md;
const nextDescription = md.description || '';
@@ -252,7 +256,7 @@ const SkillsInstalledPage: React.FC = () => {
};
loadSkillDetails();
}, [selectedSkill, isNewSkill, selectedSkillName, skills, skillDraft, getSkillDetail]);
}, [selectedSkill, isNewSkill, selectedSkillName, settingsDirectory, skills, skillDraft, getSkillDetail]);
const editorFontSize = useUIStore((state) => state.editorFontSize);
@@ -337,14 +341,14 @@ const SkillsInstalledPage: React.FC = () => {
let success: boolean;
if (isNewSkill) {
success = await createSkill(config);
success = await createSkill(config, settingsDirectory);
if (success) {
setSkillDraft(null);
setPendingFiles([]);
setSelectedSkill(skillName);
}
} else {
success = await updateSkill(skillName, config);
success = await updateSkill(skillName, config, settingsDirectory);
if (success) {
setOriginalDescription(description.trim());
setOriginalInstructions(instructions.trim());
@@ -352,7 +356,16 @@ const SkillsInstalledPage: React.FC = () => {
}
if (success) {
toast.success(isNewSkill ? t('settings.skills.page.toast.skillCreated') : t('settings.skills.page.toast.skillUpdated'));
const deferred = usePendingOpenCodeRestartStore.getState().changes.some(
(change) => change.scope === 'skills' && change.id.startsWith(`skills:${skillName}:`),
);
toast.success(
deferred
? t('settings.view.pendingRestart.saved')
: isNewSkill
? t('settings.skills.page.toast.skillCreated')
: t('settings.skills.page.toast.skillUpdated'),
);
} else {
toast.error(isNewSkill ? t('settings.skills.page.toast.createSkillFailed') : t('settings.skills.page.toast.updateSkillFailed'));
}
@@ -392,7 +405,7 @@ const SkillsInstalledPage: React.FC = () => {
try {
const { readSupportingFile } = useSkillsStore.getState();
const content = await readSupportingFile(selectedSkillName, filePath);
const content = await readSupportingFile(selectedSkillName, filePath, settingsDirectory);
setNewFileContent(content || '');
setOriginalFileContent(content || '');
} catch {
@@ -438,13 +451,13 @@ const SkillsInstalledPage: React.FC = () => {
}
const { writeSupportingFile } = useSkillsStore.getState();
const success = await writeSupportingFile(selectedSkillName, filePath, newFileContent);
const success = await writeSupportingFile(selectedSkillName, filePath, newFileContent, settingsDirectory);
if (success) {
toast.success(isEditing ? t('settings.skills.page.toast.fileUpdated', { path: filePath }) : t('settings.skills.page.toast.fileCreated', { path: filePath }));
setIsFileDialogOpen(false);
setEditingFilePath(null);
const detail = await getSkillDetail(selectedSkillName);
const detail = await getSkillDetail(selectedSkillName, settingsDirectory);
if (detail) {
setSupportingFiles(detail.sources.md.supportingFiles || []);
}
@@ -474,11 +487,11 @@ const SkillsInstalledPage: React.FC = () => {
setIsDeletingFile(true);
const { deleteSupportingFile } = useSkillsStore.getState();
const success = await deleteSupportingFile(selectedSkillName, deleteFilePath);
const success = await deleteSupportingFile(selectedSkillName, deleteFilePath, settingsDirectory);
if (success) {
toast.success(t('settings.skills.page.toast.fileDeleted', { path: deleteFilePath }));
const detail = await getSkillDetail(selectedSkillName);
const detail = await getSkillDetail(selectedSkillName, settingsDirectory);
if (detail) {
setSupportingFiles(detail.sources.md.supportingFiles || []);
}
@@ -18,7 +18,8 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { useSkillsStore, type DiscoveredSkill } from '@/stores/useSkillsStore';
import { selectSkillsForDirectory, useSkillsStore, type DiscoveredSkill } from '@/stores/useSkillsStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { useShallow } from 'zustand/react/shallow';
import { cn } from '@/lib/utils';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
@@ -49,7 +50,6 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
const {
selectedSkillName,
skills,
setSelectedSkill,
setSkillDraft,
deleteSkill,
@@ -57,7 +57,6 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
getSkillDetail,
} = useSkillsStore(useShallow((s) => ({
selectedSkillName: s.selectedSkillName,
skills: s.skills,
setSelectedSkill: s.setSelectedSkill,
setSkillDraft: s.setSkillDraft,
deleteSkill: s.deleteSkill,
@@ -65,7 +64,15 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
getSkillDetail: s.getSkillDetail,
})));
// Skills are loaded by the Settings shell when this page is active.
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const skills = useSkillsStore((state) => selectSkillsForDirectory(state, settingsDirectory));
const loadSkills = useSkillsStore((state) => state.loadSkills);
React.useEffect(() => {
void loadSkills(settingsDirectory);
}, [loadSkills, settingsDirectory]);
const bgClass = 'bg-background';
@@ -101,7 +108,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
}
setIsDeletePending(true);
const success = await deleteSkill(deleteDialogSkill.name);
const success = await deleteSkill(deleteDialogSkill.name, settingsDirectory);
if (success) {
toast.success(t('settings.skills.sidebar.toast.skillDeleted', { name: deleteDialogSkill.name }));
setDeleteDialogSkill(null);
@@ -124,7 +131,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
}
// Get full skill detail to copy
const detail = await getSkillDetail(skill.name);
const detail = await getSkillDetail(skill.name, settingsDirectory);
if (!detail) {
toast.error(t('settings.skills.sidebar.toast.duplicateLoadFailed'));
return;
@@ -173,7 +180,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
}
// Rename in place on disk so SKILL.md body and supporting files are preserved.
const success = await renameSkill(renameDialogSkill.name, sanitizedName);
const success = await renameSkill(renameDialogSkill.name, sanitizedName, settingsDirectory);
if (success) {
toast.success(t('settings.skills.sidebar.toast.skillRenamed', { name: sanitizedName }));
setSelectedSkill(sanitizedName);
@@ -439,12 +446,6 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
}) => {
const { t } = useI18n();
const isMobile = isMobileDeviceViaCSS();
const sourceLabel = skill.source === 'claude'
? t('settings.skills.sidebar.badge.claude')
: skill.source === 'agents'
? t('settings.skills.sidebar.badge.agents')
: t('settings.skills.sidebar.badge.opencode');
const badgeClassName = 'typography-micro text-muted-foreground bg-[var(--surface-muted)] px-1 rounded flex-shrink-0 leading-none pb-px border border-[var(--interactive-border)]/50';
const isBuiltIn = isBuiltInSkill(skill);
const canRename = isRenamableSkill(skill);
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false);
@@ -479,10 +480,6 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
<span className="typography-ui-label font-normal truncate text-foreground">
{skill.name}
</span>
<span className={badgeClassName}>
{skill.scope}
</span>
<span className={badgeClassName}>{sourceLabel}</span>
</div>
</button>
@@ -127,24 +127,13 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
directoryOverride?: string | null;
conflictDecisions?: Record<string, ConflictDecision>;
}) => {
// Build selection with clawdhub metadata if present
const selection: { skillDir: string; clawdhub?: { slug: string; version: string } } = {
skillDir: request.skillDir,
};
if (item?.clawdhub) {
selection.clawdhub = {
slug: item.clawdhub.slug,
version: item.clawdhub.version,
};
}
const result = await installSkills({
source: request.source,
subpath: request.subpath,
gitIdentityId: item?.gitIdentityId,
scope: request.scope,
targetSource: request.targetSource,
selections: [selection],
selections: [{ skillDir: request.skillDir }],
conflictPolicy: 'prompt',
conflictDecisions: request.conflictDecisions,
}, { directory: request.directoryOverride ?? null });
@@ -1,14 +1,11 @@
import { rankByQuery } from '@/lib/search/fuzzySearch';
import React from 'react';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import {
SettingsSection,
SETTINGS_SELECT_SIZE,
SETTINGS_SELECT_TRIGGER_CLASS,
} from '@/components/sections/shared/SettingsSection';
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import {
Dialog,
@@ -18,24 +15,16 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Icon } from "@/components/icon/Icon";
import { Icon } from '@/components/icon/Icon';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { useShallow } from 'zustand/react/shallow';
import { cn } from '@/lib/utils';
import type { SkillsCatalogItem } from '@/lib/api/types';
import type { SkillsCatalogItem, SkillsCatalogSource } from '@/lib/api/types';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { updateDesktopSettings } from '@/lib/persistence';
import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
import { AddCatalogDialog } from './AddCatalogDialog';
import { InstallSkillDialog } from './InstallSkillDialog';
@@ -48,6 +37,71 @@ interface SkillsCatalogPageProps {
showModeTabs?: boolean;
}
const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
const getRepoUrl = (source: string): string | null => {
const trimmed = source.trim();
if (!GITHUB_REPO_PATTERN.test(trimmed)) {
return null;
}
return `https://github.com/${trimmed}`;
};
const getSkillUrl = (item: SkillsCatalogItem): string | null => {
const repoUrl = getRepoUrl(item.repoSource);
if (!repoUrl) {
return null;
}
const skillPath = [item.repoSubpath, item.skillDir].filter(Boolean).join('/');
return skillPath ? `${repoUrl}/tree/HEAD/${skillPath}` : repoUrl;
};
let cachedStarsFormatter: { locale: string; formatter: Intl.NumberFormat } | null = null;
const formatStars = (stars: number): string => {
const locale = getCurrentIntlLocale();
if (!cachedStarsFormatter || cachedStarsFormatter.locale !== locale) {
cachedStarsFormatter = { locale, formatter: new Intl.NumberFormat(locale, { notation: 'compact' }) };
}
return cachedStarsFormatter.formatter.format(stars);
};
type RelativeTimeKey =
| 'common.relative.justNow'
| 'common.relative.minutesAgoShort'
| 'common.relative.hoursAgoShort'
| 'common.relative.daysAgoShort'
| 'common.relative.weeksAgoShort'
| 'common.relative.yearsAgoShort';
const formatRelativeShort = (isoDate: string): { key: RelativeTimeKey; count: number } | null => {
const timestamp = Date.parse(isoDate);
if (Number.isNaN(timestamp)) {
return null;
}
const diffMs = Date.now() - timestamp;
if (diffMs < 60_000) {
return { key: 'common.relative.justNow', count: 0 };
}
const minutes = Math.floor(diffMs / 60_000);
if (minutes < 60) {
return { key: 'common.relative.minutesAgoShort', count: minutes };
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return { key: 'common.relative.hoursAgoShort', count: hours };
}
const days = Math.floor(hours / 24);
if (days < 7) {
return { key: 'common.relative.daysAgoShort', count: days };
}
const weeks = Math.floor(days / 7);
if (weeks < 52) {
return { key: 'common.relative.weeksAgoShort', count: weeks };
}
return { key: 'common.relative.yearsAgoShort', count: Math.floor(days / 365) };
};
const loadSettings = async (): Promise<DesktopSettings | null> => {
try {
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
@@ -71,6 +125,67 @@ const loadSettings = async (): Promise<DesktopSettings | null> => {
}
};
const SourceCard: React.FC<{
source: SkillsCatalogSource;
isActive: boolean;
isLoading: boolean;
skillsCount: number | null;
onSelect: () => void;
t: ReturnType<typeof useI18n>['t'];
}> = ({ source, isActive, isLoading, skillsCount, onSelect, t }) => {
const stars = source.stars ?? null;
const updated = source.repoUpdatedAt ? formatRelativeShort(source.repoUpdatedAt) : null;
return (
<button
type="button"
onClick={onSelect}
aria-pressed={isActive}
className={cn(
'w-full min-h-24 text-left rounded-lg border bg-[var(--surface-elevated)] p-3.5 flex gap-3 items-start transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isActive
? 'border-primary'
: 'border-[var(--surface-subtle)] hover:border-[var(--interactive-border-hover)]'
)}
>
<span className="min-w-0 flex-1 block">
<span className="flex items-center gap-2">
<span className="typography-ui-label font-medium text-foreground truncate">{source.label}</span>
{isLoading ? (
<Icon name="refresh" className="h-3 w-3 animate-spin text-muted-foreground shrink-0" />
) : (
skillsCount !== null && (
<span className="typography-micro text-muted-foreground shrink-0">
{t('settings.skills.catalog.page.source.skillsCount', { count: skillsCount })}
</span>
)
)}
</span>
<span className="typography-micro font-mono text-muted-foreground block mt-0.5 truncate">{source.source}</span>
<span className="flex items-center gap-3 mt-1">
{stars !== null && (
<span
className="typography-micro text-muted-foreground flex items-center gap-1"
title={t('settings.skills.catalog.page.source.stars', { count: stars })}
>
<Icon name="star" className="h-3 w-3" />
{formatStars(stars)}
</span>
)}
{updated && (
<span className="typography-micro text-muted-foreground">
{updated.key === 'common.relative.justNow'
? t(updated.key)
: t('settings.skills.catalog.page.source.updated', { time: t(updated.key, { count: updated.count }) })}
</span>
)}
</span>
</span>
</button>
);
};
export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onModeChange, showModeTabs = true }) => {
const { t } = useI18n();
const {
@@ -80,12 +195,9 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
setSelectedSource,
loadCatalog,
loadSource,
loadMoreClawdHub,
isLoadingCatalog,
isLoadingSource,
isLoadingMore,
loadedSourceIds,
clawdhubHasMoreBySource,
lastCatalogError,
} = useSkillsCatalogStore(useShallow((s) => ({
sources: s.sources,
@@ -94,12 +206,9 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
setSelectedSource: s.setSelectedSource,
loadCatalog: s.loadCatalog,
loadSource: s.loadSource,
loadMoreClawdHub: s.loadMoreClawdHub,
isLoadingCatalog: s.isLoadingCatalog,
isLoadingSource: s.isLoadingSource,
isLoadingMore: s.isLoadingMore,
loadedSourceIds: s.loadedSourceIds,
clawdhubHasMoreBySource: s.clawdhubHasMoreBySource,
lastCatalogError: s.lastCatalogError,
})));
@@ -109,43 +218,70 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
const [installItem, setInstallItem] = React.useState<SkillsCatalogItem | null>(null);
const [isRemovingCatalog, setIsRemovingCatalog] = React.useState(false);
const [isRemoveCatalogDialogOpen, setIsRemoveCatalogDialogOpen] = React.useState(false);
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
React.useEffect(() => {
void loadCatalog();
}, [loadCatalog]);
// Load every source in the background so global search covers all of them.
React.useEffect(() => {
if (!selectedSourceId) {
const unloaded = sources.filter((src) => !loadedSourceIds[src.id]);
if (unloaded.length === 0) {
return;
}
if (!loadedSourceIds[selectedSourceId]) {
void loadSource(selectedSourceId);
let cancelled = false;
const loadRest = async () => {
for (const src of unloaded) {
if (cancelled) {
return;
}
await loadSource(src.id);
}
};
void loadRest();
return () => {
cancelled = true;
};
}, [sources, loadedSourceIds, loadSource]);
React.useEffect(() => {
if (!selectedSourceId || loadedSourceIds[selectedSourceId]) {
return;
}
void loadSource(selectedSourceId);
}, [selectedSourceId, loadedSourceIds, loadSource]);
const items = React.useMemo(() => {
if (!selectedSourceId) return [];
return itemsBySource[selectedSourceId] || [];
}, [itemsBySource, selectedSourceId]);
React.useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
searchInputRef.current?.focus();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, []);
const isSearching = search.trim().length > 0;
const filtered = React.useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return items;
return items.filter((item) => {
const name = item.skillName.toLowerCase();
const desc = (item.description || '').toLowerCase();
const fm = (item.frontmatterName || '').toLowerCase();
return name.includes(q) || desc.includes(q) || fm.includes(q);
});
}, [items, search]);
if (isSearching) {
return rankByQuery(
sources.flatMap((src) => itemsBySource[src.id] || []),
search,
(item) => [item.skillName, item.frontmatterName, item.description],
);
}
if (!selectedSourceId) {
return [];
}
return itemsBySource[selectedSourceId] || [];
}, [sources, itemsBySource, selectedSourceId, search, isSearching]);
const selectedSource = React.useMemo(() => sources.find((s) => s.id === selectedSourceId) || null, [sources, selectedSourceId]);
const isCustomSource = Boolean(selectedSourceId && selectedSourceId.startsWith('custom:'));
const isClawdHubSource = selectedSource?.source === 'clawdhub:registry' || selectedSource?.sourceType === 'clawdhub';
const hasMoreClawdHub = Boolean(
selectedSourceId && (clawdhubHasMoreBySource[selectedSourceId] ?? true)
);
const removeSelectedCatalog = async () => {
if (!selectedSourceId || !isCustomSource) {
@@ -165,6 +301,17 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
}
};
const listTitle = isSearching
? t('settings.skills.catalog.page.list.searchTitle')
: (selectedSource?.label ?? '');
// The selected source has no items yet and a load is in flight — show the
// loading state instead of a stale list from the previously selected source.
const isSelectedSourceLoading = !isSearching
&& selectedSourceId !== null
&& !loadedSourceIds[selectedSourceId]
&& (isLoadingSource || isLoadingCatalog);
return (
<>
<SettingsPageLayout
@@ -190,90 +337,74 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
</div>
)}
<p className="typography-meta text-muted-foreground mb-4">
{t('settings.skills.catalog.page.subtitle')}
</p>
<div data-settings-item="skills.catalog.search" className="mb-5">
<div className="relative max-w-md">
<Icon name="search" className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
ref={searchInputRef}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('settings.skills.catalog.page.searchAllPlaceholder')}
className={cn('h-8 pl-8 w-full', search && 'pr-8')}
/>
{search && (
<button
type="button"
onClick={() => {
setSearch('');
searchInputRef.current?.focus();
}}
className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center h-4 w-4 rounded text-muted-foreground hover:text-foreground transition-colors"
title={t('settings.skills.catalog.page.search.clear')}
>
<Icon name="close" className="h-3 w-3" />
</button>
)}
</div>
</div>
<SettingsSection
title={t('settings.skills.catalog.page.section.sourceRepository')}
title={t('settings.skills.catalog.page.section.sources')}
divider={false}
settingsItem="skills.catalog.source"
contentClassName="space-y-0"
>
<div className="flex flex-wrap items-center gap-2 py-1.5">
<Select
value={selectedSourceId || ''}
onValueChange={(v) => setSelectedSource(v)}
>
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={cn(SETTINGS_SELECT_TRIGGER_CLASS, 'w-fit')}>
<SelectValue placeholder={t('settings.skills.catalog.page.field.selectSourcePlaceholder')}>
{selectedSource?.label}
</SelectValue>
</SelectTrigger>
<SelectContent align="start">
{sources.map((src) => (
<SelectItem key={src.id} value={src.id}>
{src.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 py-1.5">
{sources.map((src) => (
<SourceCard
key={src.id}
source={src}
isActive={src.id === selectedSourceId}
isLoading={isLoadingSource && !loadedSourceIds[src.id]}
skillsCount={loadedSourceIds[src.id] ? (itemsBySource[src.id] || []).length : null}
onSelect={() => setSelectedSource(src.id)}
t={t}
/>
))}
<Button
variant="outline"
size="xs"
className="!font-normal h-6 w-6 px-0"
onClick={() => {
if (selectedSourceId) {
void loadSource(selectedSourceId, { refresh: true });
} else {
void loadCatalog({ refresh: true });
}
}}
disabled={isLoadingCatalog || isLoadingSource}
title={t('settings.skills.catalog.page.actions.refreshTitle')}
>
<Icon name="refresh" className={cn("h-3.5 w-3.5", (isLoadingCatalog || isLoadingSource) && "animate-spin")} />
</Button>
{isCustomSource && (
<Button
variant="ghost"
size="xs"
className="!font-normal h-6 w-6 px-0 text-[var(--status-error)] hover:text-[var(--status-error)]"
onClick={() => setIsRemoveCatalogDialogOpen(true)}
disabled={isRemovingCatalog}
title={t('settings.skills.catalog.page.actions.removeCatalogTitle')}
>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
</Button>
)}
<Button
data-settings-item="skills.catalog.add-catalog"
size="xs"
className="!font-normal gap-1"
onClick={() => setAddCatalogOpen(true)}
>
<Icon name="add" className="h-3.5 w-3.5" /> {t('settings.skills.catalog.page.actions.addCatalog')}
</Button>
</div>
<div data-settings-item="skills.catalog.search" className="py-1.5">
<div className="relative">
<Icon name="search" className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('settings.skills.catalog.shared.field.searchSkillsPlaceholder')}
className="h-7 pl-8 w-full sm:w-64"
/>
</div>
<span className="typography-meta text-muted-foreground mt-1 block">
{isLoadingCatalog
? t('settings.skills.catalog.page.loading.catalog')
: t('settings.skills.catalog.page.foundCount', { count: filtered.length })}
<button
type="button"
data-settings-item="skills.catalog.add-catalog"
onClick={() => setAddCatalogOpen(true)}
className="min-h-24 text-left rounded-lg border border-dashed border-[var(--interactive-border)] hover:border-[var(--interactive-border-hover)] hover:bg-[var(--surface-muted)] p-3.5 flex gap-3 items-start transition-colors"
>
<span className="flex items-center justify-center rounded-md bg-transparent text-muted-foreground w-8 h-8 shrink-0">
<Icon name="add" className="h-4 w-4" />
</span>
</div>
<span className="min-w-0">
<span className="typography-ui-label text-muted-foreground block">
{t('settings.skills.catalog.page.source.addOwnTitle')}
</span>
<span className="typography-micro text-muted-foreground/70 block mt-0.5">
{t('settings.skills.catalog.page.source.addOwnDescription')}
</span>
</span>
</button>
</div>
</SettingsSection>
{lastCatalogError && (
@@ -286,21 +417,63 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
)}
<SettingsSection>
{filtered.length === 0 && !isLoadingSource ? (
<div className="py-8 text-center text-muted-foreground">
<p className="typography-body">{t('settings.skills.catalog.page.empty.noSkillsTitle')}</p>
<p className="typography-meta mt-1 opacity-75">{t('settings.skills.catalog.page.empty.noSkillsDescription')}</p>
</div>
) : isLoadingSource ? (
<div className="flex items-center justify-between gap-2 pb-2">
<div className="flex items-center gap-2 min-w-0">
<span className="typography-micro font-medium uppercase tracking-wide text-muted-foreground truncate">
{listTitle}
</span>
<span className="typography-micro text-muted-foreground/70 shrink-0">
{t('settings.skills.catalog.page.foundCount', { count: filtered.length })}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="xs"
className="!font-normal h-6 w-6 px-0"
onClick={() => {
if (selectedSourceId && !isSearching) {
void loadSource(selectedSourceId, { refresh: true });
} else {
void loadCatalog({ refresh: true });
}
}}
disabled={isLoadingCatalog || isLoadingSource}
title={t('settings.skills.catalog.page.actions.refreshTitle')}
>
<Icon name="refresh" className={cn('h-3.5 w-3.5', (isLoadingCatalog || isLoadingSource) && 'animate-spin')} />
</Button>
{isCustomSource && !isSearching && (
<Button
variant="ghost"
size="xs"
className="!font-normal h-6 w-6 px-0 text-[var(--status-error)] hover:text-[var(--status-error)]"
onClick={() => setIsRemoveCatalogDialogOpen(true)}
disabled={isRemovingCatalog}
title={t('settings.skills.catalog.page.actions.removeCatalogTitle')}
>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
{isSelectedSourceLoading || (isLoadingSource && filtered.length === 0) ? (
<div className="py-8 text-center text-muted-foreground">
<Icon name="refresh" className="mx-auto mb-3 h-5 w-5 animate-spin opacity-50" />
<p className="typography-meta">{t('settings.skills.catalog.page.loading.skills')}</p>
</div>
) : filtered.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
<p className="typography-body">{t('settings.skills.catalog.page.empty.noSkillsTitle')}</p>
<p className="typography-meta mt-1 opacity-75">{t('settings.skills.catalog.page.empty.noSkillsDescription')}</p>
</div>
) : (
<div className="divide-y divide-[var(--surface-subtle)]">
{filtered.map((item) => {
const installed = item.installed?.isInstalled;
const installedScope = item.installed?.scope;
const skillUrl = getSkillUrl(item);
return (
<div key={`${item.sourceId}:${item.skillDir}`} className="py-2">
@@ -326,24 +499,28 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
<div className="typography-meta text-muted-foreground/50 mt-0.5 italic">{t('settings.skills.catalog.shared.noDescription')}</div>
)}
{item.clawdhub && (
<div className="typography-micro text-muted-foreground mt-1.5 flex items-center gap-3">
{item.clawdhub.owner && (
<span>{t('settings.skills.catalog.page.byOwnerPrefix')} <span className="font-medium text-foreground/80">{item.clawdhub.owner}</span></span>
)}
<span className="flex items-center gap-1">
<Icon name="download" className="h-3 w-3" />
{item.clawdhub.downloads?.toLocaleString() ?? 0}
</span>
{(item.clawdhub.stars ?? 0) > 0 && (
<span className="flex items-center gap-1">
<Icon name="star" className="h-3 w-3" />
{item.clawdhub.stars}
</span>
)}
<span className="bg-[var(--surface-muted)] px-1.5 py-0.5 rounded">v{item.clawdhub.version}</span>
</div>
)}
<div className="typography-micro text-muted-foreground/80 mt-1 flex items-center gap-2 min-w-0">
{skillUrl ? (
<a
href={skillUrl}
target="_blank"
rel="noreferrer"
className="font-mono hover:underline truncate inline-flex items-center gap-1"
title={t('settings.skills.catalog.page.skill.viewOnGithub')}
>
<Icon name="github" className="h-3 w-3 shrink-0" />
{item.repoSource}
</a>
) : (
<span className="font-mono truncate">{item.repoSource}</span>
)}
{item.skillDir && (
<>
<span className="opacity-40">·</span>
<span className="truncate">{item.skillDir}</span>
</>
)}
</div>
{item.warnings?.length ? (
<div className="typography-micro text-[var(--status-warning)] mt-1.5 bg-[var(--status-warning)]/10 px-2 py-1 rounded w-fit">
@@ -352,37 +529,43 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
) : null}
</div>
<Button
variant="outline"
size="xs"
className="!font-normal shrink-0"
disabled={!item.installable}
onClick={() => {
setInstallItem(item);
setInstallDialogOpen(true);
}}
>
{t('settings.skills.catalog.shared.actions.install')}
</Button>
<div className="flex items-center gap-1.5 shrink-0">
{skillUrl && (
<Button
variant="ghost"
size="xs"
className="!font-normal h-6 w-6 px-0"
onClick={() => window.open(skillUrl, '_blank', 'noreferrer')}
title={t('settings.skills.catalog.page.skill.viewOnGithub')}
>
<Icon name="external-link" className="h-3.5 w-3.5" />
</Button>
)}
{installed ? (
<span className="text-[var(--status-success)] flex items-center justify-center w-7 h-7" title={t('settings.skills.catalog.page.badge.installed', { scope: installedScope || '' })}>
<Icon name="check" className="h-4 w-4" />
</span>
) : (
<Button
variant="outline"
size="xs"
className="!font-normal"
disabled={!item.installable}
onClick={() => {
setInstallItem(item);
setInstallDialogOpen(true);
}}
>
{t('settings.skills.catalog.shared.actions.install')}
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{isClawdHubSource && hasMoreClawdHub && !isLoadingSource && filtered.length > 0 && (
<div className="flex justify-center mt-2">
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => void loadMoreClawdHub()}
disabled={isLoadingMore}
>
{isLoadingMore ? t('settings.skills.catalog.page.loading.more') : t('settings.skills.catalog.page.actions.loadMoreSkills')}
</Button>
</div>
)}
</SettingsSection>
</SettingsPageLayout>
@@ -1,99 +0,0 @@
import React from 'react';
import { cn } from '@/lib/utils';
import type { PaceInfo } from '@/lib/quota';
import { getPaceStatusColor, formatRemainingTime } from '@/lib/quota';
import { useI18n } from '@/lib/i18n';
interface PaceIndicatorProps {
paceInfo: PaceInfo;
className?: string;
/** Compact mode shows just the status dot and prediction */
compact?: boolean;
}
/**
* Visual indicator showing whether usage is on track, slightly fast, or too fast.
* Inspired by opencode-bar's pace visualization.
*/
export const PaceIndicator: React.FC<PaceIndicatorProps> = ({
paceInfo,
className,
compact = false,
}) => {
const { t } = useI18n();
const statusColor = getPaceStatusColor(paceInfo.status);
const statusLabel = React.useMemo(() => {
switch (paceInfo.status) {
case 'on-track':
return t('settings.usage.pace.status.onTrack');
case 'slightly-fast':
return t('settings.usage.pace.status.slightlyFast');
case 'too-fast':
return t('settings.usage.pace.status.tooFast');
case 'exhausted':
return t('settings.usage.pace.status.usedUp');
}
}, [paceInfo.status, t]);
const predictionTooltip = t('settings.usage.pace.predictionTooltip', { prediction: paceInfo.predictText });
if (compact) {
return (
<div className={cn('flex items-center gap-1.5', className)}>
<div
className="h-2 w-2 rounded-full flex-shrink-0"
style={{ backgroundColor: statusColor }}
title={statusLabel}
/>
<span
className="typography-micro tabular-nums"
style={{ color: statusColor }}
title={paceInfo.isExhausted ? undefined : predictionTooltip}
>
{paceInfo.isExhausted ? (
<>{t('settings.usage.pace.wait', { duration: formatRemainingTime(paceInfo.remainingSeconds) })}</>
) : (
<>{t('settings.usage.pace.prediction', { prediction: paceInfo.predictText })}</>
)}
</span>
</div>
);
}
return (
<div className={cn('flex items-center justify-between gap-2', className)}>
<div className="flex items-center gap-1.5">
{!paceInfo.isExhausted && (
<span className="typography-micro text-muted-foreground">
{t('settings.usage.pace.rate', { rate: paceInfo.paceRateText })}
</span>
)}
</div>
<div className="flex items-center gap-1.5">
<span
className="typography-micro tabular-nums"
style={{ color: statusColor }}
>
{paceInfo.isExhausted ? (
<>
<span className="font-medium">{statusLabel}</span>
<span className="text-muted-foreground">{t('settings.usage.pace.waitSeparator')}</span>
<span className="font-medium">{formatRemainingTime(paceInfo.remainingSeconds)}</span>
</>
) : (
<span title={predictionTooltip}>
<span className="text-muted-foreground">{t('settings.usage.pace.predictionLabel')}</span>
<span className="font-medium">{paceInfo.predictText}</span>
</span>
)}
</span>
<div
className="h-2 w-2 rounded-full flex-shrink-0"
style={{ backgroundColor: statusColor }}
title={statusLabel}
/>
</div>
</div>
);
};
@@ -5,8 +5,8 @@ import { toast } from '@/components/ui';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
type ProviderId = 'opencode-go' | 'ollama-cloud' | 'cursor';
type Status = { configured: boolean; workspaceId?: string; secretMasked?: string };
type ProviderId = 'ollama-cloud' | 'cursor';
type Status = { configured: boolean; secretMasked?: string };
export const QuotaCredentials: React.FC<{ providerId: ProviderId; providerName: string }> = ({ providerId, providerName }) => {
const { t } = useI18n();
@@ -17,7 +17,7 @@ export const QuotaCredentials: React.FC<{ providerId: ProviderId; providerName:
React.useEffect(() => { void runtimeFetch(route).then(async (response) => {
if (!response.ok) throw new Error();
const next = await response.json() as Status;
setStatus(next); setValues(next.workspaceId ? { workspaceId: next.workspaceId } : {});
setStatus(next); setValues({});
}).catch(() => setStatus({ configured: false })); }, [route]);
const request = async (path: string, method: string, body?: object) => {
setBusy(true);
@@ -26,17 +26,15 @@ export const QuotaCredentials: React.FC<{ providerId: ProviderId; providerName:
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error);
if (payload?.configured !== undefined) setStatus(payload);
setValues((current) => current.workspaceId ? { workspaceId: current.workspaceId } : {} as Record<string, string>);
setValues({});
toast.success(t('settings.providers.page.quotaCredentials.saved', { provider: providerName }));
} catch (error) { toast.error(error instanceof Error && error.message ? error.message : t('settings.providers.page.openCodeGo.saveFailed')); }
finally { setBusy(false); }
};
const field = (name: string, label: string, placeholder: string) => <label className="block typography-ui-label text-foreground">{label}<Input className="mt-1 h-7 font-mono text-xs" type={name === 'workspaceId' ? 'text' : 'password'} autoComplete="off" value={values[name] ?? ''} onChange={(event) => setValues((current) => ({ ...current, [name]: event.target.value }))} placeholder={status?.secretMasked ?? placeholder} /></label>;
const field = (name: string, label: string, placeholder: string) => <label className="block typography-ui-label text-foreground">{label}<Input className="mt-1 h-7 font-mono text-xs" type="password" autoComplete="off" value={values[name] ?? ''} onChange={(event) => setValues((current) => ({ ...current, [name]: event.target.value }))} placeholder={status?.secretMasked ?? placeholder} /></label>;
return <div data-settings-item={`usage.${providerId}-credentials`} className="mb-8">
<div className="mb-1 px-1"><h3 className="typography-ui-header font-medium text-foreground">{providerName}</h3></div>
<section className="space-y-3 px-2 pb-2 pt-0">
{providerId === 'opencode-go' && field('workspaceId', t('settings.providers.page.openCodeGo.workspaceId'), 'wrk_...')}
{providerId === 'opencode-go' && field('authCookie', t('settings.providers.page.openCodeGo.authCookie'), 'auth=...')}
{providerId === 'ollama-cloud' && field('cookie', t('settings.providers.page.openCodeGo.authCookie'), 'session=...')}
{providerId === 'cursor' && field('accessToken', t('settings.providers.page.quotaCredentials.accessToken'), t('settings.providers.page.quotaCredentials.tokenPlaceholder'))}
{providerId === 'cursor' && field('refreshToken', t('settings.providers.page.quotaCredentials.refreshToken'), t('settings.providers.page.quotaCredentials.tokenPlaceholder'))}
@@ -1,8 +1,6 @@
import React from 'react';
import type { UsageWindow } from '@/types';
import { formatQuotaValueLabel, formatQuotaResetLabel, formatWindowLabel, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota';
import { formatQuotaValueLabel, formatQuotaResetLabel, formatWindowLabel } from '@/lib/quota';
import { UsageProgressBar } from './UsageProgressBar';
import { PaceIndicator } from './PaceIndicator';
import { useQuotaStore } from '@/stores/useQuotaStore';
import { Checkbox } from '@/components/ui/checkbox';
import { useUIStore } from '@/stores/useUIStore';
@@ -25,7 +23,6 @@ export const UsageCard: React.FC<UsageCardProps> = ({
onToggle,
}) => {
const displayMode = useQuotaStore((state) => state.displayMode);
const showPredValues = useQuotaStore((state) => state.showPredValues);
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
const displayPercent = displayMode === 'remaining' ? window.remainingPercent : window.usedPercent;
const barLabel = displayMode === 'remaining' ? 'remaining' : 'used';
@@ -33,18 +30,6 @@ export const UsageCard: React.FC<UsageCardProps> = ({
const resetLabel = formatQuotaResetLabel(window.resetAt, window.resetAfterFormatted ?? window.resetAtFormatted, timeFormatPreference);
const windowLabel = formatWindowLabel(title);
const paceInfo = React.useMemo(() => {
return calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, title);
}, [window.usedPercent, window.resetAt, window.windowSeconds, title]);
const expectedMarkerPercent = React.useMemo(() => {
if (!paceInfo || paceInfo.dailyAllocationPercent === null) {
return null;
}
const expectedUsed = calculateExpectedUsagePercent(paceInfo.elapsedRatio);
return displayMode === 'remaining' ? 100 - expectedUsed : expectedUsed;
}, [paceInfo, displayMode]);
return (
<div className="py-3">
<div className="flex items-center justify-between gap-3">
@@ -72,7 +57,6 @@ export const UsageCard: React.FC<UsageCardProps> = ({
<UsageProgressBar
percent={displayPercent}
tonePercent={window.usedPercent}
expectedMarkerPercent={expectedMarkerPercent}
className="h-1.5"
/>
<div className="mt-1 flex items-center justify-between">
@@ -85,11 +69,6 @@ export const UsageCard: React.FC<UsageCardProps> = ({
</div>
</div>
{paceInfo && showPredValues && (
<div className="mt-1.5">
<PaceIndicator paceInfo={paceInfo} />
</div>
)}
</div>
);
};
@@ -76,8 +76,11 @@ export const UsagePage: React.FC = () => {
const providerMeta = QUOTA_PROVIDERS.find((provider) => provider.id === selectedProviderId);
const providerName = providerMeta?.name ?? selectedProviderId ?? t('settings.usage.sidebar.title');
const usage = selectedResult?.usage;
const selectedProviderError = selectedResult?.configured && !selectedResult.ok
? selectedResult.error
: null;
const showInDropdown = selectedProviderId ? dropdownProviderIds.includes(selectedProviderId) : false;
const hasCredentialsForm = selectedProviderId === 'opencode-go' || selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor';
const hasCredentialsForm = selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor';
const handleDropdownToggle = React.useCallback((enabled: boolean) => {
if (!selectedProviderId) {
return;
@@ -159,19 +162,24 @@ export const UsagePage: React.FC = () => {
description={
isLoading ? (
<span className="animate-pulse typography-settings-description text-muted-foreground">{t('settings.usage.page.header.refreshing')}</span>
) : selectedResult?.planLabel ? (
t('settings.usage.page.header.lastUpdatedWithPlan', {
plan: selectedResult.planLabel,
time: formatTime(lastUpdated, timeFormatPreference),
})
) : (
t('settings.usage.page.header.lastUpdated', { time: formatTime(lastUpdated, timeFormatPreference) })
)
}
showSaveStatus
>
<SettingsSection divider={false} settingsItem="usage.header-menu">
<SettingsSection divider={false} settingsItem="usage.work-status-panel">
<SettingsCheckboxRow
checked={showInDropdown}
onChange={handleDropdownToggle}
label={t('settings.usage.page.options.showInHeader')}
ariaLabel={t('settings.usage.page.options.showInHeaderAria')}
info={t('settings.usage.page.options.showInHeaderTooltip')}
label={t('settings.usage.page.options.showInWorkStatus')}
ariaLabel={t('settings.usage.page.options.showInWorkStatusAria')}
info={t('settings.usage.page.options.showInWorkStatusTooltip')}
/>
</SettingsSection>
@@ -179,10 +187,10 @@ export const UsagePage: React.FC = () => {
<p className="typography-ui-label text-foreground pb-8">{t('settings.usage.page.state.noData')}</p>
)}
{error && (
{(error || selectedProviderError) && (
<div className="mb-8 rounded-lg border border-[var(--status-error-border)] bg-[var(--status-error-background)] px-4 py-3">
<p className="typography-ui-label font-medium text-[var(--status-error)]">{t('settings.usage.page.state.refreshFailedTitle')}</p>
<p className="typography-meta text-[var(--status-error)]/80 mt-1">{error}</p>
<p className="typography-meta text-[var(--status-error)]/80 mt-1">{error ?? selectedProviderError}</p>
</div>
)}
@@ -196,7 +204,7 @@ export const UsagePage: React.FC = () => {
</div>
)}
{(selectedProviderId === 'opencode-go' || selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor') && (
{(selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor') && (
<QuotaCredentials providerId={selectedProviderId} providerName={providerName} />
)}
@@ -6,24 +6,15 @@ interface UsageProgressBarProps {
percent: number | null;
tonePercent?: number | null;
className?: string;
/**
* Position (0-100) to show a marker indicating expected usage based on time elapsed.
* Used for weekly/monthly quotas to show where usage "should" be if evenly distributed.
*/
expectedMarkerPercent?: number | null;
}
export const UsageProgressBar: React.FC<UsageProgressBarProps> = ({
percent,
tonePercent,
className,
expectedMarkerPercent,
}) => {
const clamped = clampPercent(percent) ?? 0;
const tone = resolveUsageTone(tonePercent ?? percent);
const markerClamped = expectedMarkerPercent != null
? Math.max(0, Math.min(100, expectedMarkerPercent))
: null;
const fillStyle = tone === 'critical'
? { backgroundColor: 'var(--status-error)' }
@@ -41,14 +32,6 @@ export const UsageProgressBar: React.FC<UsageProgressBarProps> = ({
aria-valuemin={0}
aria-valuemax={100}
/>
{markerClamped != null && markerClamped > 0 && markerClamped < 100 && (
<div
className="absolute top-0 h-full w-0.5 bg-foreground"
style={{ left: `${markerClamped}%` }}
title={`Expected usage if spread evenly: ${Math.round(markerClamped)}% of quota`}
aria-hidden="true"
/>
)}
</div>
);
};
@@ -2,8 +2,6 @@ import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Icon } from "@/components/icon/Icon";
import { cn } from '@/lib/utils';
@@ -35,21 +33,15 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
const setSelectedProvider = useQuotaStore((state) => state.setSelectedProvider);
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
const isLoading = useQuotaStore((state) => state.isLoading);
const usageAutoRefresh = useQuotaStore((state) => state.autoRefresh);
const usageRefreshIntervalMs = useQuotaStore((state) => state.refreshIntervalMs);
const usageDisplayMode = useQuotaStore((state) => state.displayMode);
const setUsageAutoRefresh = useQuotaStore((state) => state.setAutoRefresh);
const setUsageRefreshInterval = useQuotaStore((state) => state.setRefreshInterval);
const setUsageDisplayMode = useQuotaStore((state) => state.setDisplayMode);
const showPredValues = useQuotaStore((state) => state.showPredValues);
const setShowPredValues = useQuotaStore((state) => state.setShowPredValues);
const loadUsageSettings = useQuotaStore((state) => state.loadSettings);
React.useEffect(() => {
void loadUsageSettings();
}, [loadUsageSettings]);
const persistUsageSettings = React.useCallback(async (changes: { usageAutoRefresh?: boolean; usageRefreshIntervalMs?: number; usageDisplayMode?: 'usage' | 'remaining'; usageDropdownProviders?: string[]; usageShowPredValues?: boolean }) => {
const persistUsageSettings = React.useCallback(async (changes: { usageDisplayMode?: 'usage' | 'remaining'; usageDropdownProviders?: string[] }) => {
try {
await updateDesktopSettings(changes);
} catch (error) {
@@ -57,20 +49,6 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
}
}, []);
const handleUsageAutoRefreshChange = React.useCallback((enabled: boolean) => {
setUsageAutoRefresh(enabled);
void persistUsageSettings({ usageAutoRefresh: enabled });
}, [persistUsageSettings, setUsageAutoRefresh]);
const handleUsageRefreshIntervalChange = React.useCallback((value: string) => {
const next = Number(value);
if (!Number.isFinite(next)) {
return;
}
setUsageRefreshInterval(next);
void persistUsageSettings({ usageRefreshIntervalMs: next });
}, [persistUsageSettings, setUsageRefreshInterval]);
const handleUsageDisplayModeChange = React.useCallback((value: string) => {
if (value !== 'usage' && value !== 'remaining') {
return;
@@ -79,11 +57,6 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
void persistUsageSettings({ usageDisplayMode: value });
}, [persistUsageSettings, setUsageDisplayMode]);
const handleShowPredValuesChange = React.useCallback((enabled: boolean) => {
setShowPredValues(enabled);
void persistUsageSettings({ usageShowPredValues: enabled });
}, [persistUsageSettings, setShowPredValues]);
const bgClass = 'bg-background';
return (
@@ -93,34 +66,6 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">{t('settings.usage.sidebar.total', { count: QUOTA_PROVIDERS.length })}</span>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Checkbox
checked={usageAutoRefresh}
onChange={handleUsageAutoRefreshChange}
ariaLabel={t('settings.usage.sidebar.actions.toggleAutoRefreshAria')}
/>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{t('settings.usage.sidebar.tooltip.autoRefresh')}
</TooltipContent>
</Tooltip>
<Select
value={String(usageRefreshIntervalMs)}
onValueChange={handleUsageRefreshIntervalChange}
disabled={!usageAutoRefresh}
>
<SelectTrigger className="w-fit">
<SelectValue placeholder={t('settings.usage.sidebar.field.intervalPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="30000">30s</SelectItem>
<SelectItem value="60000">1m</SelectItem>
<SelectItem value="300000">5m</SelectItem>
</SelectContent>
</Select>
<Button size="sm"
variant="ghost"
className="h-7 w-7 px-0 text-muted-foreground"
@@ -145,16 +90,6 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
</SelectContent>
</Select>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<span className="typography-micro text-muted-foreground">
{t('settings.usage.sidebar.field.showPredictions')}
</span>
<Checkbox
checked={showPredValues}
onChange={handleShowPredValuesChange}
ariaLabel={t('settings.usage.sidebar.field.showPredictions')}
/>
</div>
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2 overflow-x-hidden">