Merge remote-tracking branch 'origin/main' into port-2619
# Conflicts: # packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user