Add i18n foundation and translations (#1027)
* feat: add i18n foundation * feat: localize sessions sidebar * Localize multirun/scheduled tasks and fix dialog dropdown interactions * localize git sidebar surface and add zh-CN keys * feat(ui): localize context panel, diff/plan views, and context sidebar content * fix(config): resolve user config home via fs/home before embedded home * localize header/chat UI and complete model/worktree panel strings * localize worktree + github issue/pr dialog flows * localize settings sections and split settings i18n dictionaries * localize additional settings sections and sidebars * localize more settings pages and dialogs * fix settings select trigger localization * localize tunnel settings ui surface * localize additional settings sections * localize keyboard shortcuts labels in settings * localize terminal and utility dialogs surfaces * feat(i18n): localize remaining UI strings * Add Ukrainian locale * Add Spanish locale * Add Brazilian Portuguese locale * Polish locale translations
This commit is contained in:
committed by
GitHub
parent
87db2ea210
commit
7d7285655d
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { SIDEBAR_SECTION_CONFIG_MAP, SIDEBAR_SECTION_DESCRIPTIONS } from '@/constants/sidebar';
|
||||
import type { SidebarSection } from '@/constants/sidebar';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface SectionPlaceholderProps {
|
||||
sectionId: SidebarSection;
|
||||
@@ -8,6 +9,7 @@ interface SectionPlaceholderProps {
|
||||
}
|
||||
|
||||
export const SectionPlaceholder: React.FC<SectionPlaceholderProps> = ({ sectionId, variant }) => {
|
||||
const { t } = useI18n();
|
||||
const config = SIDEBAR_SECTION_CONFIG_MAP[sectionId];
|
||||
const Icon = config.icon;
|
||||
|
||||
@@ -36,7 +38,7 @@ export const SectionPlaceholder: React.FC<SectionPlaceholderProps> = ({ sectionI
|
||||
{SIDEBAR_SECTION_DESCRIPTIONS[sectionId]}
|
||||
</p>
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground/60">Coming soon...</p>
|
||||
<p className="typography-meta text-muted-foreground/60">{t('settings.common.state.comingSoon')}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import { cn } from '@/lib/utils';
|
||||
import { ModelSelector } from './ModelSelector';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -181,6 +182,7 @@ const buildPermissionConfigWithGlobal = (
|
||||
|
||||
|
||||
export const AgentsPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const { selectedAgentName, getAgentByName, createAgent, updateAgent, agents, agentDraft, setAgentDraft } = useAgentsStore();
|
||||
|
||||
@@ -325,6 +327,15 @@ export const AgentsPage: React.FC = () => {
|
||||
hasDefaultHint,
|
||||
};
|
||||
}, [getPatternRules, getWildcardOverride, globalPermission]);
|
||||
const permissionActionLabel = React.useCallback((value: PermissionAction): string => {
|
||||
if (value === 'allow') return t('settings.common.permission.allow');
|
||||
if (value === 'deny') return t('settings.common.permission.deny');
|
||||
return t('settings.common.permission.ask');
|
||||
}, [t]);
|
||||
const permissionScopeLabel = React.useCallback((value: PermissionAction | 'global'): string => {
|
||||
if (value === 'global') return t('settings.common.scope.global');
|
||||
return permissionActionLabel(value);
|
||||
}, [permissionActionLabel, t]);
|
||||
|
||||
const availablePermissionNames = React.useMemo(() => {
|
||||
const names = new Set<string>();
|
||||
@@ -376,7 +387,7 @@ export const AgentsPage: React.FC = () => {
|
||||
const applyPendingRule = React.useCallback((action: PermissionAction) => {
|
||||
const name = pendingRuleName.trim();
|
||||
if (!name) {
|
||||
toast.error('Permission name is required');
|
||||
toast.error(t('settings.agents.page.toast.permissionNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -397,7 +408,7 @@ export const AgentsPage: React.FC = () => {
|
||||
}, [globalPermission, pendingRuleName, pendingRulePattern, removeRule, setGlobalPermissionAndPrune, upsertRule]);
|
||||
|
||||
const formatPermissionLabel = React.useCallback((permissionName: string): string => {
|
||||
if (permissionName === '*') return 'Default';
|
||||
if (permissionName === '*') return t('settings.agents.page.permissions.defaultLabel');
|
||||
if (permissionName === 'webfetch') return 'WebFetch';
|
||||
if (permissionName === 'websearch') return 'WebSearch';
|
||||
if (permissionName === 'codesearch') return 'CodeSearch';
|
||||
@@ -411,7 +422,7 @@ export const AgentsPage: React.FC = () => {
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
||||
.join(' ');
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setPendingRuleName('');
|
||||
@@ -528,13 +539,13 @@ export const AgentsPage: React.FC = () => {
|
||||
const agentName = isNewAgent ? draftName.trim().replace(/\s+/g, '-') : selectedAgentName?.trim();
|
||||
|
||||
if (!agentName) {
|
||||
toast.error('Agent name is required');
|
||||
toast.error(t('settings.agents.sidebar.toast.agentNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicate name when creating new agent
|
||||
if (isNewAgent && agents.some((a) => a.name === agentName)) {
|
||||
toast.error('An agent with this name already exists');
|
||||
toast.error(t('settings.agents.sidebar.toast.agentExists'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -566,13 +577,13 @@ export const AgentsPage: React.FC = () => {
|
||||
}
|
||||
|
||||
if (success) {
|
||||
toast.success(isNewAgent ? 'Agent created successfully' : 'Agent updated successfully');
|
||||
toast.success(isNewAgent ? t('settings.agents.page.toast.created') : t('settings.agents.page.toast.updated'));
|
||||
} else {
|
||||
toast.error(isNewAgent ? 'Failed to create agent' : 'Failed to update agent');
|
||||
toast.error(isNewAgent ? t('settings.agents.page.toast.createFailed') : t('settings.agents.page.toast.updateFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving agent:', error);
|
||||
const message = error instanceof Error && error.message ? error.message : 'An error occurred while saving';
|
||||
const message = error instanceof Error && error.message ? error.message : t('settings.agents.page.toast.saveUnexpectedError');
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
@@ -585,8 +596,8 @@ export const AgentsPage: React.FC = () => {
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<RiRobot2Line className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
||||
<p className="typography-body">Select an agent from the sidebar</p>
|
||||
<p className="typography-meta mt-1 opacity-75">or create a new one</p>
|
||||
<p className="typography-body">{t('settings.agents.page.empty.title')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.agents.page.empty.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -600,10 +611,10 @@ export const AgentsPage: React.FC = () => {
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground truncate">
|
||||
{isNewAgent ? 'New Agent' : selectedAgentName}
|
||||
{isNewAgent ? t('settings.agents.page.title.new') : selectedAgentName}
|
||||
</h2>
|
||||
<p className="typography-meta text-muted-foreground truncate">
|
||||
{isNewAgent ? 'Configure a new assistant persona' : 'Edit agent settings'}
|
||||
{isNewAgent ? t('settings.agents.page.subtitle.new') : t('settings.agents.page.subtitle.edit')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -612,7 +623,7 @@ export const AgentsPage: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Identity & Role
|
||||
{t('settings.agents.page.section.identityRole')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -621,7 +632,7 @@ export const AgentsPage: React.FC = () => {
|
||||
{isNewAgent && (
|
||||
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">Agent Name</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.agents.page.field.agentName')}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
|
||||
<div className="flex items-center">
|
||||
@@ -629,25 +640,25 @@ export const AgentsPage: React.FC = () => {
|
||||
<Input
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
placeholder="agent-name"
|
||||
placeholder={t('settings.agents.page.field.agentNamePlaceholder')}
|
||||
className="h-7 w-40 px-2"
|
||||
/>
|
||||
</div>
|
||||
<Select value={draftScope} onValueChange={(v) => setDraftScope(v as AgentScope)}>
|
||||
<SelectTrigger className="w-fit min-w-[100px]">
|
||||
<SelectValue placeholder="Scope" />
|
||||
<SelectValue placeholder={t('settings.agents.page.field.scopePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="user">
|
||||
<div className="flex items-center gap-2">
|
||||
<RiUser3Line className="h-3.5 w-3.5" />
|
||||
<span>Global</span>
|
||||
<span>{t('settings.common.scope.global')}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="project">
|
||||
<div className="flex items-center gap-2">
|
||||
<RiFolderLine className="h-3.5 w-3.5" />
|
||||
<span>Project</span>
|
||||
<span>{t('settings.common.scope.project')}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -657,12 +668,12 @@ export const AgentsPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
<div className="py-1.5">
|
||||
<span className="typography-ui-label text-foreground">Description</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.common.field.description')}</span>
|
||||
<div className="mt-1.5">
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What does this agent do?"
|
||||
placeholder={t('settings.agents.page.field.descriptionPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full resize-none min-h-[60px] bg-transparent"
|
||||
/>
|
||||
@@ -672,13 +683,13 @@ export const AgentsPage: React.FC = () => {
|
||||
<div className="pb-1.5 pt-0.5">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="typography-ui-label text-foreground">Mode</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.agents.page.field.mode')}</span>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Primary vs Subagent visibility
|
||||
{t('settings.agents.page.field.modeTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -690,7 +701,7 @@ export const AgentsPage: React.FC = () => {
|
||||
onClick={() => setMode('primary')}
|
||||
className="!font-normal"
|
||||
>
|
||||
Primary
|
||||
{t('settings.agents.page.mode.primary')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="chip"
|
||||
@@ -699,7 +710,7 @@ export const AgentsPage: React.FC = () => {
|
||||
onClick={() => setMode('subagent')}
|
||||
className="!font-normal"
|
||||
>
|
||||
Subagent
|
||||
{t('settings.agents.page.mode.subagent')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="chip"
|
||||
@@ -708,7 +719,7 @@ export const AgentsPage: React.FC = () => {
|
||||
onClick={() => setMode('all')}
|
||||
className="!font-normal"
|
||||
>
|
||||
All
|
||||
{t('settings.agents.page.mode.all')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -721,7 +732,7 @@ export const AgentsPage: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Model & Parameters
|
||||
{t('settings.agents.page.section.modelParameters')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -729,7 +740,7 @@ export const AgentsPage: React.FC = () => {
|
||||
|
||||
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">Override Model</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.agents.page.field.overrideModel')}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
|
||||
<ModelSelector
|
||||
@@ -749,17 +760,17 @@ export const AgentsPage: React.FC = () => {
|
||||
<div className={cn("py-1.5", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
|
||||
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "sm:w-56 shrink-0")}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="typography-ui-label text-foreground">Temperature</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.agents.page.field.temperature')}</span>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Controls randomness. Higher = creative, Lower = focused.
|
||||
{t('settings.agents.page.field.temperatureTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<span className="typography-meta text-muted-foreground">0.0 to 2.0</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.agents.page.field.temperatureRange')}</span>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2", isMobile ? "w-full" : "w-fit")}>
|
||||
<NumberInput
|
||||
@@ -781,8 +792,8 @@ export const AgentsPage: React.FC = () => {
|
||||
variant="ghost"
|
||||
onClick={() => setTemperature(undefined)}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Clear temperature override"
|
||||
title="Clear"
|
||||
aria-label={t('settings.agents.page.field.clearTemperatureAria')}
|
||||
title={t('settings.common.actions.clear')}
|
||||
>
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -793,17 +804,17 @@ export const AgentsPage: React.FC = () => {
|
||||
<div className={cn("py-1.5", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
|
||||
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "sm:w-56 shrink-0")}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="typography-ui-label text-foreground">Top P</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.agents.page.field.topP')}</span>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Nucleus sampling diversity. Lower = likely tokens only.
|
||||
{t('settings.agents.page.field.topPTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<span className="typography-meta text-muted-foreground">0.0 to 1.0</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.agents.page.field.topPRange')}</span>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2", isMobile ? "w-full" : "w-fit")}>
|
||||
<NumberInput
|
||||
@@ -825,8 +836,8 @@ export const AgentsPage: React.FC = () => {
|
||||
variant="ghost"
|
||||
onClick={() => setTopP(undefined)}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Clear top p override"
|
||||
title="Clear"
|
||||
aria-label={t('settings.agents.page.field.clearTopPAria')}
|
||||
title={t('settings.common.actions.clear')}
|
||||
>
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -841,7 +852,7 @@ export const AgentsPage: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
System Prompt
|
||||
{t('settings.agents.page.section.systemPrompt')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -849,7 +860,7 @@ export const AgentsPage: React.FC = () => {
|
||||
<Textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="You are an expert coding assistant..."
|
||||
placeholder={t('settings.agents.page.field.systemPromptPlaceholder')}
|
||||
rows={8}
|
||||
className="w-full font-mono typography-meta min-h-[120px] max-h-[60vh] bg-transparent resize-y"
|
||||
/>
|
||||
@@ -860,7 +871,7 @@ export const AgentsPage: React.FC = () => {
|
||||
<div className="mb-2">
|
||||
<div className="mb-1 px-1 flex items-center justify-between gap-4">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Tool Permissions
|
||||
{t('settings.agents.page.section.toolPermissions')}
|
||||
</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -868,7 +879,7 @@ export const AgentsPage: React.FC = () => {
|
||||
className="!font-normal"
|
||||
onClick={() => setShowPermissionEditor((prev) => !prev)}
|
||||
>
|
||||
{showPermissionEditor ? 'Hide Editor' : 'Advanced Editor'}
|
||||
{showPermissionEditor ? t('settings.agents.page.permissions.hideEditor') : t('settings.agents.page.permissions.advancedEditor')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -886,12 +897,12 @@ export const AgentsPage: React.FC = () => {
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{patternRulesCount > 0 ? (
|
||||
<span className="typography-micro text-muted-foreground bg-[var(--surface-muted)] px-1.5 py-0.5 rounded">Global: {summary}</span>
|
||||
<span className="typography-micro text-muted-foreground bg-[var(--surface-muted)] px-1.5 py-0.5 rounded">{t('settings.agents.page.permissions.globalSummary', { summary })}</span>
|
||||
) : (
|
||||
<span className={cn("typography-micro capitalize px-1.5 py-0.5 rounded", summary === 'allow' ? "text-[var(--status-success)] bg-[var(--status-success)]/10" : summary === 'deny' ? "text-[var(--status-error)] bg-[var(--status-error)]/10" : "text-[var(--status-warning)] bg-[var(--status-warning)]/10")}>{summary}</span>
|
||||
)}
|
||||
{patternRulesCount > 0 && (
|
||||
<span className="typography-micro text-muted-foreground bg-[var(--surface-muted)] px-1.5 py-0.5 rounded">Rules: {patternSummary}</span>
|
||||
<span className="typography-micro text-muted-foreground bg-[var(--surface-muted)] px-1.5 py-0.5 rounded">{t('settings.agents.page.permissions.rulesSummary', { summary: patternSummary })}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -902,7 +913,7 @@ export const AgentsPage: React.FC = () => {
|
||||
<div className="space-y-6 px-2">
|
||||
<div className="flex items-center justify-between gap-4 py-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="typography-ui-label text-foreground">Global Default</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.agents.page.permissions.globalDefault')}</span>
|
||||
<span className="typography-micro text-muted-foreground/70 font-mono">*</span>
|
||||
</div>
|
||||
<Select
|
||||
@@ -910,12 +921,12 @@ export const AgentsPage: React.FC = () => {
|
||||
onValueChange={(value) => setGlobalPermissionAndPrune(value as PermissionAction)}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue />
|
||||
<SelectValue>{permissionActionLabel(globalPermission)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="allow">Allow</SelectItem>
|
||||
<SelectItem value="ask">Ask</SelectItem>
|
||||
<SelectItem value="deny">Deny</SelectItem>
|
||||
<SelectItem value="allow">{t('settings.common.permission.allow')}</SelectItem>
|
||||
<SelectItem value="ask">{t('settings.common.permission.ask')}</SelectItem>
|
||||
<SelectItem value="deny">{t('settings.common.permission.deny')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -937,14 +948,14 @@ export const AgentsPage: React.FC = () => {
|
||||
<span className="typography-micro text-muted-foreground/70 font-mono">{permissionName}</span>
|
||||
</div>
|
||||
<div className="typography-micro text-muted-foreground">
|
||||
{patternRulesCount > 0 ? `Global: ${defaultAction}` : defaultAction}
|
||||
{patternRulesCount > 0 ? t('settings.agents.page.permissions.globalSummary', { summary: defaultAction }) : defaultAction}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 pl-2 mt-1">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 py-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="typography-micro text-muted-foreground">Pattern</span>
|
||||
<span className="typography-micro text-muted-foreground">{t('settings.agents.page.permissions.pattern')}</span>
|
||||
<span className="typography-micro font-mono text-foreground bg-[var(--surface-muted)] px-1 rounded">*</span>
|
||||
{wildcardOverride && (
|
||||
<Button size="sm"
|
||||
@@ -967,13 +978,13 @@ export const AgentsPage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[90px]">
|
||||
<SelectValue />
|
||||
<SelectValue>{permissionScopeLabel(wildcardValue as PermissionAction | 'global')}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">Global</SelectItem>
|
||||
<SelectItem value="global">{t('settings.common.scope.global')}</SelectItem>
|
||||
{wildcardOptions.map((action) => (
|
||||
<SelectItem key={action} value={action} className="capitalize">
|
||||
{action}
|
||||
{permissionActionLabel(action)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -989,10 +1000,10 @@ export const AgentsPage: React.FC = () => {
|
||||
return (
|
||||
<div key={ruleKey} className="flex flex-wrap items-center justify-between gap-2 py-0.5 border-t border-[var(--surface-subtle)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="typography-micro text-muted-foreground">Pattern</span>
|
||||
<span className="typography-micro text-muted-foreground">{t('settings.agents.page.permissions.pattern')}</span>
|
||||
<span className="typography-micro font-mono text-foreground bg-[var(--surface-muted)] px-1 rounded">{rule.pattern}</span>
|
||||
{isAdded && <span className="typography-micro text-[var(--status-success)]">New</span>}
|
||||
{isModified && <span className="typography-micro text-[var(--status-warning)]">Modified</span>}
|
||||
{isAdded && <span className="typography-micro text-[var(--status-success)]">{t('settings.common.badge.new')}</span>}
|
||||
{isModified && <span className="typography-micro text-[var(--status-warning)]">{t('settings.common.badge.modified')}</span>}
|
||||
{(isAdded || isModified) && (
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
@@ -1008,12 +1019,12 @@ export const AgentsPage: React.FC = () => {
|
||||
onValueChange={(value) => setRuleAction(rule.permission, rule.pattern, value as PermissionAction)}
|
||||
>
|
||||
<SelectTrigger className="w-[90px]">
|
||||
<SelectValue />
|
||||
<SelectValue>{permissionActionLabel(rule.action)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="allow">Allow</SelectItem>
|
||||
<SelectItem value="ask">Ask</SelectItem>
|
||||
<SelectItem value="deny">Deny</SelectItem>
|
||||
<SelectItem value="allow">{t('settings.common.permission.allow')}</SelectItem>
|
||||
<SelectItem value="ask">{t('settings.common.permission.ask')}</SelectItem>
|
||||
<SelectItem value="deny">{t('settings.common.permission.deny')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1026,14 +1037,14 @@ export const AgentsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--surface-subtle)] pt-3">
|
||||
<h4 className="typography-ui-label text-foreground mb-2">Add Custom Rule</h4>
|
||||
<h4 className="typography-ui-label text-foreground mb-2">{t('settings.agents.page.permissions.addCustomRule')}</h4>
|
||||
<div className="flex flex-col sm:flex-row items-end sm:items-center gap-2">
|
||||
<Select value={pendingRuleName} onValueChange={setPendingRuleName}>
|
||||
<SelectTrigger className="w-full sm:w-[160px]">
|
||||
{pendingRuleName ? (
|
||||
<span className="truncate">{formatPermissionLabel(pendingRuleName)}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Permission...</span>
|
||||
<span className="text-muted-foreground">{t('settings.agents.page.permissions.permissionPlaceholder')}</span>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -1050,14 +1061,14 @@ export const AgentsPage: React.FC = () => {
|
||||
<Input
|
||||
value={pendingRulePattern}
|
||||
onChange={(e) => setPendingRulePattern(e.target.value)}
|
||||
placeholder="Pattern (e.g. *)"
|
||||
placeholder={t('settings.agents.page.permissions.patternPlaceholder')}
|
||||
className="h-7 flex-1 font-mono text-xs"
|
||||
/>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => applyPendingRule('allow')}>Allow</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => applyPendingRule('ask')}>Ask</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => applyPendingRule('deny')}>Deny</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => applyPendingRule('allow')}>{t('settings.common.permission.allow')}</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => applyPendingRule('ask')}>{t('settings.common.permission.ask')}</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => applyPendingRule('deny')}>{t('settings.common.permission.deny')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1073,7 +1084,7 @@ export const AgentsPage: React.FC = () => {
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
{isSaving ? t('settings.common.actions.saving') : t('settings.common.actions.saveChanges')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { Agent } from '@opencode-ai/sdk/v2';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
|
||||
import { SidebarGroup } from '@/components/sections/shared/SidebarGroup';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface AgentsSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
@@ -99,6 +100,7 @@ const rulesetToPermissionConfig = (ruleset: unknown): AgentDraft['permission'] =
|
||||
};
|
||||
|
||||
export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const [renameDialogAgent, setRenameDialogAgent] = React.useState<Agent | null>(null);
|
||||
const [renameNewName, setRenameNewName] = React.useState('');
|
||||
const [confirmActionAgent, setConfirmActionAgent] = React.useState<Agent | null>(null);
|
||||
@@ -141,7 +143,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
||||
|
||||
const handleDeleteAgent = async (agent: Agent) => {
|
||||
if (isAgentBuiltIn(agent)) {
|
||||
toast.error('Built-in agents cannot be deleted');
|
||||
toast.error(t('settings.agents.sidebar.toast.builtInCannotDelete'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -173,15 +175,15 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
||||
|
||||
if (success) {
|
||||
if (confirmActionType === 'delete') {
|
||||
toast.success(`Agent "${confirmActionAgent.name}" deleted successfully`);
|
||||
toast.success(t('settings.agents.sidebar.toast.agentDeleted', { name: confirmActionAgent.name }));
|
||||
} else {
|
||||
toast.success(`Agent "${confirmActionAgent.name}" reset to default`);
|
||||
toast.success(t('settings.agents.sidebar.toast.agentReset', { name: confirmActionAgent.name }));
|
||||
}
|
||||
closeConfirmActionDialog();
|
||||
} else if (confirmActionType === 'delete') {
|
||||
toast.error('Failed to delete agent');
|
||||
toast.error(t('settings.agents.sidebar.toast.deleteFailed'));
|
||||
} else {
|
||||
toast.error('Failed to reset agent');
|
||||
toast.error(t('settings.agents.sidebar.toast.resetFailed'));
|
||||
}
|
||||
|
||||
setIsConfirmActionPending(false);
|
||||
@@ -231,7 +233,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
||||
const sanitizedName = renameNewName.trim().replace(/\s+/g, '-');
|
||||
|
||||
if (!sanitizedName) {
|
||||
toast.error('Agent name is required');
|
||||
toast.error(t('settings.agents.sidebar.toast.agentNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -241,7 +243,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
||||
}
|
||||
|
||||
if (agents.some((a) => a.name === sanitizedName)) {
|
||||
toast.error('An agent with this name already exists');
|
||||
toast.error(t('settings.agents.sidebar.toast.agentExists'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -270,10 +272,10 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
||||
toast.success(`Agent renamed to "${sanitizedName}"`);
|
||||
setSelectedAgent(sanitizedName);
|
||||
} else {
|
||||
toast.error('Failed to remove old agent after rename');
|
||||
toast.error(t('settings.agents.sidebar.toast.removeOldAfterRenameFailed'));
|
||||
}
|
||||
} else {
|
||||
toast.error('Failed to rename agent');
|
||||
toast.error(t('settings.agents.sidebar.toast.renameFailed'));
|
||||
}
|
||||
|
||||
setRenameDialogAgent(null);
|
||||
@@ -319,10 +321,10 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">Agents</h2>
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.agents.sidebar.title')}</h2>
|
||||
<SettingsProjectSelector className="mb-3" />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">Total {visibleAgents.length}</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.agents.sidebar.total', { count: visibleAgents.length })}</span>
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
|
||||
@@ -337,15 +339,15 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
||||
{visibleAgents.length === 0 ? (
|
||||
<div className="py-12 px-4 text-center text-muted-foreground">
|
||||
<RiRobot2Line className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
||||
<p className="typography-ui-label font-medium">No agents configured</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Use the + button above to create one</p>
|
||||
<p className="typography-ui-label font-medium">{t('settings.agents.sidebar.empty.title')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.agents.sidebar.empty.description')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{builtInAgents.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Built-in Agents
|
||||
{t('settings.agents.sidebar.section.builtIn')}
|
||||
</div>
|
||||
{builtInAgents.map((agent) => (
|
||||
<AgentListItem
|
||||
@@ -370,7 +372,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
||||
{customAgents.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pb-1.5 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Custom Agents
|
||||
{t('settings.agents.sidebar.section.custom')}
|
||||
</div>
|
||||
|
||||
{/* Grouped agents by subfolder */}
|
||||
@@ -437,11 +439,11 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{confirmActionType === 'delete' ? 'Delete Agent' : 'Reset Agent'}</DialogTitle>
|
||||
<DialogTitle>{confirmActionType === 'delete' ? t('settings.agents.sidebar.dialog.deleteTitle') : t('settings.agents.sidebar.dialog.resetTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{confirmActionType === 'delete'
|
||||
? `Are you sure you want to delete agent "${confirmActionAgent?.name}"?`
|
||||
: `Are you sure you want to reset agent "${confirmActionAgent?.name}" to its default configuration?`}
|
||||
? t('settings.agents.sidebar.dialog.deleteDescription', { name: confirmActionAgent?.name ?? '' })
|
||||
: t('settings.agents.sidebar.dialog.resetDescription', { name: confirmActionAgent?.name ?? '' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -451,10 +453,10 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
||||
onClick={closeConfirmActionDialog}
|
||||
disabled={isConfirmActionPending}
|
||||
>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleConfirmAction} disabled={isConfirmActionPending}>
|
||||
{confirmActionType === 'delete' ? 'Delete' : 'Reset'}
|
||||
{confirmActionType === 'delete' ? t('settings.common.actions.delete') : t('settings.common.actions.reset')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -464,15 +466,15 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
||||
<Dialog open={renameDialogAgent !== null} onOpenChange={(open) => !open && setRenameDialogAgent(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename Agent</DialogTitle>
|
||||
<DialogTitle>{t('settings.agents.sidebar.renameDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter a new name for the agent "@{renameDialogAgent?.name}"
|
||||
{t('settings.agents.sidebar.renameDialog.description', { name: renameDialogAgent?.name ?? '' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={renameNewName}
|
||||
onChange={(e) => setRenameNewName(e.target.value)}
|
||||
placeholder="New agent name..."
|
||||
placeholder={t('settings.agents.sidebar.renameDialog.placeholder')}
|
||||
className="text-foreground placeholder:text-muted-foreground"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
@@ -486,10 +488,10 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
||||
variant="ghost"
|
||||
onClick={() => setRenameDialogAgent(null)}
|
||||
>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleRenameAgent}>
|
||||
Rename
|
||||
{t('settings.common.actions.rename')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -523,6 +525,7 @@ const AgentListItem: React.FC<AgentListItemProps> = ({
|
||||
isMenuOpen,
|
||||
onMenuOpenChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const extAgent = agent as Agent & { scope?: AgentScope };
|
||||
const isMobile = isMobileDeviceViaCSS();
|
||||
|
||||
@@ -550,7 +553,7 @@ const AgentListItem: React.FC<AgentListItemProps> = ({
|
||||
{getAgentModeIcon(agent.mode)}
|
||||
{(extAgent.scope || isAgentBuiltIn(agent)) && (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
{isAgentBuiltIn(agent) ? 'system' : extAgent.scope}
|
||||
{isAgentBuiltIn(agent) ? t('settings.agents.sidebar.badge.system') : extAgent.scope}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -580,7 +583,7 @@ const AgentListItem: React.FC<AgentListItemProps> = ({
|
||||
}}
|
||||
>
|
||||
<RiEditLine className="h-4 w-4 mr-px" />
|
||||
Rename
|
||||
{t('settings.common.actions.rename')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
@@ -591,7 +594,7 @@ const AgentListItem: React.FC<AgentListItemProps> = ({
|
||||
}}
|
||||
>
|
||||
<RiFileCopyLine className="h-4 w-4 mr-px" />
|
||||
Duplicate
|
||||
{t('settings.common.actions.duplicate')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{onReset && (
|
||||
@@ -602,7 +605,7 @@ const AgentListItem: React.FC<AgentListItemProps> = ({
|
||||
}}
|
||||
>
|
||||
<RiRestartLine className="h-4 w-4 mr-px" />
|
||||
Reset
|
||||
{t('settings.common.actions.reset')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
@@ -615,7 +618,7 @@ const AgentListItem: React.FC<AgentListItemProps> = ({
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-px" />
|
||||
Delete
|
||||
{t('settings.common.actions.delete')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { useModelLists } from '@/hooks/useModelLists';
|
||||
import type { ModelMetadata } from '@/types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type ProviderModel = Record<string, unknown> & { id?: string; name?: string };
|
||||
|
||||
@@ -55,6 +56,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
allowedProviderIds,
|
||||
placeholder
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
const modelsMetadata = useConfigStore((state) => state.modelsMetadata);
|
||||
const isMobile = useUIStore(state => state.isMobile);
|
||||
@@ -211,8 +213,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
"model-favorite-button flex h-4 w-4 items-center justify-center hover:text-primary/80",
|
||||
isFavorite ? "text-primary" : "text-muted-foreground"
|
||||
)}
|
||||
aria-label={isFavorite ? "Unfavorite" : "Favorite"}
|
||||
title={isFavorite ? "Remove from favorites" : "Add to favorites"}
|
||||
aria-label={isFavorite ? t('settings.agents.modelSelector.actions.unfavorite') : t('settings.agents.modelSelector.actions.favorite')}
|
||||
title={isFavorite ? t('settings.agents.modelSelector.actions.removeFromFavorites') : t('settings.agents.modelSelector.actions.addToFavorites')}
|
||||
>
|
||||
{isFavorite ? (
|
||||
<RiStarFill className="h-3.5 w-3.5" />
|
||||
@@ -266,14 +268,14 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
<MobileOverlayPanel
|
||||
open={isMobilePanelOpen}
|
||||
onClose={closeMobilePanel}
|
||||
title="Select model"
|
||||
title={t('settings.agents.modelSelector.title')}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{/* Favorites Section for Mobile */}
|
||||
{favoriteModelsList.length > 0 && (
|
||||
<div className="rounded-xl border border-border/40 bg-[var(--surface-elevated)] mb-2">
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Favorites
|
||||
{t('settings.agents.modelSelector.section.favorites')}
|
||||
</div>
|
||||
<div className="border-t border-border/20">
|
||||
{favoriteModelsList.map(({ model, providerID, modelID }) => {
|
||||
@@ -312,7 +314,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
toggleFavoriteModel(providerID, modelID);
|
||||
}}
|
||||
className="model-favorite-button flex h-8 w-8 items-center justify-center text-primary hover:text-primary/80 active:scale-95 touch-manipulation"
|
||||
aria-label="Unfavorite"
|
||||
aria-label={t('settings.agents.modelSelector.actions.unfavorite')}
|
||||
>
|
||||
<RiStarFill className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -327,7 +329,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
{recentModelsList.length > 0 && (
|
||||
<div className="rounded-xl border border-border/40 bg-[var(--surface-elevated)] mb-2">
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Recents
|
||||
{t('settings.agents.modelSelector.section.recents')}
|
||||
</div>
|
||||
<div className="border-t border-border/20">
|
||||
{recentModelsList.map(({ model, providerID, modelID }) => {
|
||||
@@ -366,7 +368,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
toggleFavoriteModel(providerID, modelID);
|
||||
}}
|
||||
className="model-favorite-button flex h-8 w-8 items-center justify-center text-muted-foreground/50 hover:text-primary/80 active:scale-95 touch-manipulation"
|
||||
aria-label="Favorite"
|
||||
aria-label={t('settings.agents.modelSelector.actions.favorite')}
|
||||
>
|
||||
<RiStarLine className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -400,7 +402,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
{provider.name}
|
||||
</span>
|
||||
{isActiveProvider && (
|
||||
<span className="typography-micro text-primary/80">Current</span>
|
||||
<span className="typography-micro text-primary/80">{t('settings.agents.modelSelector.badge.current')}</span>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
@@ -448,7 +450,9 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
? "text-primary"
|
||||
: "text-muted-foreground/50"
|
||||
)}
|
||||
aria-label={isFavoriteModel(provider.id as string, modelItem.id as string) ? "Unfavorite" : "Favorite"}
|
||||
aria-label={isFavoriteModel(provider.id as string, modelItem.id as string)
|
||||
? t('settings.agents.modelSelector.actions.unfavorite')
|
||||
: t('settings.agents.modelSelector.actions.favorite')}
|
||||
>
|
||||
{isFavoriteModel(provider.id as string, modelItem.id as string) ? (
|
||||
<RiStarFill className="h-4 w-4" />
|
||||
@@ -478,7 +482,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
closeMobilePanel();
|
||||
}}
|
||||
>
|
||||
<span className="typography-meta text-muted-foreground">{placeholder || 'No model (optional)'}</span>
|
||||
<span className="typography-meta text-muted-foreground">{placeholder || t('settings.agents.modelSelector.noModelOptional')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</MobileOverlayPanel>
|
||||
@@ -506,7 +510,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
<RiPencilAiLine className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
<span className="typography-meta font-medium text-foreground">
|
||||
{providerId && modelId ? `${providerId}/${modelId}` : (placeholder || 'Select model...')}
|
||||
{providerId && modelId ? `${providerId}/${modelId}` : (placeholder || t('settings.agents.modelSelector.selectPlaceholder'))}
|
||||
</span>
|
||||
</div>
|
||||
<RiArrowDownSLine className="h-3 w-3 text-muted-foreground" />
|
||||
@@ -530,7 +534,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
<RiPencilAiLine className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="typography-ui-label font-normal whitespace-nowrap text-foreground">
|
||||
{providerId && modelId ? `${providerId}/${modelId}` : (placeholder || 'Not selected')}
|
||||
{providerId && modelId ? `${providerId}/${modelId}` : (placeholder || t('settings.agents.modelSelector.notSelected'))}
|
||||
</span>
|
||||
<RiArrowDownSLine className="h-4 w-4 flex-shrink-0 text-muted-foreground/50" />
|
||||
</div>
|
||||
@@ -595,7 +599,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
<RiSearchLine className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search models"
|
||||
placeholder={t('settings.agents.modelSelector.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -617,7 +621,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
onClick={() => handleProviderAndModelChange('', '')}
|
||||
>
|
||||
<RiCloseLine className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">{placeholder || 'Not selected'}</span>
|
||||
<span className="text-muted-foreground">{placeholder || t('settings.agents.modelSelector.notSelected')}</span>
|
||||
{!providerId && !modelId && (
|
||||
<RiCheckLine className="h-4 w-4 text-primary ml-auto" />
|
||||
)}
|
||||
@@ -627,7 +631,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
|
||||
{!hasResults && searchQuery && (
|
||||
<div className="px-2 py-4 text-center typography-meta text-muted-foreground">
|
||||
No models found
|
||||
{t('settings.agents.modelSelector.state.noModelsFound')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -636,7 +640,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
<div>
|
||||
<DropdownMenuLabel className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 border-b border-border/30">
|
||||
<RiStarFill className="h-4 w-4 text-primary" />
|
||||
Favorites
|
||||
{t('settings.agents.modelSelector.section.favorites')}
|
||||
</DropdownMenuLabel>
|
||||
{filteredFavorites.map(({ model, providerID, modelID }) => {
|
||||
const idx = currentFlatIndex++;
|
||||
@@ -651,7 +655,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
{filteredFavorites.length > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuLabel className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 border-b border-border/30">
|
||||
<RiTimeLine className="h-4 w-4" />
|
||||
Recent
|
||||
{t('settings.agents.modelSelector.section.recent')}
|
||||
</DropdownMenuLabel>
|
||||
{filteredRecents.map(({ model, providerID, modelID }) => {
|
||||
const idx = currentFlatIndex++;
|
||||
@@ -687,7 +691,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
|
||||
{/* Keyboard hints footer */}
|
||||
<div className="px-3 pt-1 pb-1.5 border-t border-border/40 typography-micro text-muted-foreground">
|
||||
↑↓ navigate • Enter select • Esc close
|
||||
{t('settings.agents.modelSelector.keyboardHints')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useDeviceInfo } from '@/lib/device';
|
||||
import { RiArrowDownSLine, RiRobot2Line } from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface AgentSelectorProps {
|
||||
agentName: string;
|
||||
@@ -27,6 +28,7 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
className,
|
||||
filter,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const configAgents = useConfigStore((state) => state.agents);
|
||||
const agentsStoreAgents = useAgentsStore((state) => state.agents);
|
||||
const loadAgentsStore = useAgentsStore((state) => state.loadAgents);
|
||||
@@ -61,11 +63,11 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
if (!isActuallyMobile) return null;
|
||||
|
||||
return (
|
||||
<MobileOverlayPanel
|
||||
open={isMobilePanelOpen}
|
||||
onClose={closeMobilePanel}
|
||||
title="Select agent"
|
||||
>
|
||||
<MobileOverlayPanel
|
||||
open={isMobilePanelOpen}
|
||||
onClose={closeMobilePanel}
|
||||
title={t('settings.commands.agentSelector.title')}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
@@ -78,7 +80,9 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
closeMobilePanel();
|
||||
}}
|
||||
>
|
||||
<span className={cn('typography-meta', !agentName ? 'font-medium' : 'text-muted-foreground')}>Not selected</span>
|
||||
<span className={cn('typography-meta', !agentName ? 'font-medium' : 'text-muted-foreground')}>
|
||||
{t('settings.commands.agentSelector.notSelected')}
|
||||
</span>
|
||||
{!agentName && <div className="h-2 w-2 rounded-full bg-primary" />}
|
||||
</button>
|
||||
{agents.map((agent) => {
|
||||
@@ -130,7 +134,7 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
<div className="flex items-center gap-2">
|
||||
<RiRobot2Line className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="typography-meta font-medium text-foreground">
|
||||
{agentName || 'Select agent...'}
|
||||
{agentName || t('settings.commands.agentSelector.selectAgentPlaceholder')}
|
||||
</span>
|
||||
</div>
|
||||
<RiArrowDownSLine className="h-3 w-3 text-muted-foreground" />
|
||||
@@ -144,7 +148,7 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
)}>
|
||||
<RiRobot2Line className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||
<span className="typography-micro font-medium whitespace-nowrap">
|
||||
{agentName || 'Not selected'}
|
||||
{agentName || t('settings.commands.agentSelector.notSelected')}
|
||||
</span>
|
||||
<RiArrowDownSLine className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
@@ -154,7 +158,7 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
className="typography-meta"
|
||||
onSelect={() => handleAgentChange('')}
|
||||
>
|
||||
<span className="text-muted-foreground">Not selected</span>
|
||||
<span className="text-muted-foreground">{t('settings.commands.agentSelector.notSelected')}</span>
|
||||
</DropdownMenuItem>
|
||||
{agents.map((agent) => (
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -15,8 +15,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export const CommandsPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { selectedCommandName, getCommandByName, createCommand, updateCommand, commands, commandDraft, setCommandDraft } = useCommandsStore();
|
||||
|
||||
const selectedCommand = selectedCommandName ? getCommandByName(selectedCommandName) : null;
|
||||
@@ -104,17 +106,17 @@ export const CommandsPage: React.FC = () => {
|
||||
const commandName = isNewCommand ? draftName.trim().replace(/\s+/g, '-') : selectedCommandName?.trim();
|
||||
|
||||
if (!commandName) {
|
||||
toast.error('Command name is required');
|
||||
toast.error(t('settings.commands.sidebar.toast.commandNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!template.trim()) {
|
||||
toast.error('Command template is required');
|
||||
toast.error(t('settings.commands.page.toast.templateRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNewCommand && commands.some((cmd) => cmd.name === commandName)) {
|
||||
toast.error('A command with this name already exists');
|
||||
toast.error(t('settings.commands.sidebar.toast.commandExists'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -144,13 +146,13 @@ export const CommandsPage: React.FC = () => {
|
||||
}
|
||||
|
||||
if (success) {
|
||||
toast.success(isNewCommand ? 'Command created successfully' : 'Command updated successfully');
|
||||
toast.success(isNewCommand ? t('settings.commands.page.toast.created') : t('settings.commands.page.toast.updated'));
|
||||
} else {
|
||||
toast.error(isNewCommand ? 'Failed to create command' : 'Failed to update command');
|
||||
toast.error(isNewCommand ? t('settings.commands.page.toast.createFailed') : t('settings.commands.page.toast.updateFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving command:', error);
|
||||
toast.error('An error occurred while saving');
|
||||
toast.error(t('settings.commands.page.toast.saveUnexpectedError'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -161,8 +163,8 @@ export const CommandsPage: React.FC = () => {
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<RiTerminalBoxLine className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
||||
<p className="typography-body">Select a command from the sidebar</p>
|
||||
<p className="typography-meta mt-1 opacity-75">or create a new one</p>
|
||||
<p className="typography-body">{t('settings.commands.page.empty.title')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.commands.page.empty.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -176,10 +178,10 @@ export const CommandsPage: React.FC = () => {
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground truncate">
|
||||
{isNewCommand ? 'New Command' : `/${selectedCommandName}`}
|
||||
{isNewCommand ? t('settings.commands.page.title.new') : `/${selectedCommandName}`}
|
||||
</h2>
|
||||
<p className="typography-meta text-muted-foreground truncate">
|
||||
{isNewCommand ? 'Configure a new slash command' : 'Edit command settings'}
|
||||
{isNewCommand ? t('settings.commands.page.subtitle.new') : t('settings.commands.page.subtitle.edit')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,7 +190,7 @@ export const CommandsPage: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Identity
|
||||
{t('settings.commands.page.section.identity')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -197,7 +199,7 @@ export const CommandsPage: React.FC = () => {
|
||||
{isNewCommand && (
|
||||
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">Command Name</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.commands.page.field.commandName')}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
|
||||
<div className="flex items-center">
|
||||
@@ -205,25 +207,25 @@ export const CommandsPage: React.FC = () => {
|
||||
<Input
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
placeholder="command-name"
|
||||
placeholder={t('settings.commands.page.field.commandNamePlaceholder')}
|
||||
className="h-7 w-40 px-2"
|
||||
/>
|
||||
</div>
|
||||
<Select value={draftScope} onValueChange={(v) => setDraftScope(v as CommandScope)}>
|
||||
<SelectTrigger className="w-fit min-w-[100px]">
|
||||
<SelectValue placeholder="Scope" />
|
||||
<SelectValue placeholder={t('settings.agents.page.field.scopePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="user">
|
||||
<div className="flex items-center gap-2">
|
||||
<RiUser3Line className="h-3.5 w-3.5" />
|
||||
<span>Global</span>
|
||||
<span>{t('settings.common.scope.global')}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="project">
|
||||
<div className="flex items-center gap-2">
|
||||
<RiFolderLine className="h-3.5 w-3.5" />
|
||||
<span>Project</span>
|
||||
<span>{t('settings.common.scope.project')}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -233,12 +235,12 @@ export const CommandsPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
<div className="py-1.5">
|
||||
<span className="typography-ui-label text-foreground">Description</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.common.field.description')}</span>
|
||||
<div className="mt-1.5">
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What does this command do?"
|
||||
placeholder={t('settings.commands.page.field.descriptionPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full resize-none min-h-[60px] bg-transparent"
|
||||
/>
|
||||
@@ -252,7 +254,7 @@ export const CommandsPage: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Execution Context
|
||||
{t('settings.commands.page.section.executionContext')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -260,7 +262,7 @@ export const CommandsPage: React.FC = () => {
|
||||
|
||||
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">Override Agent</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.commands.page.field.overrideAgent')}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
|
||||
<AgentSelector
|
||||
@@ -272,7 +274,7 @@ export const CommandsPage: React.FC = () => {
|
||||
|
||||
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">Override Model</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.agents.page.field.overrideModel')}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
|
||||
<ModelSelector
|
||||
@@ -296,7 +298,7 @@ export const CommandsPage: React.FC = () => {
|
||||
<div className="mb-2">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Command Template
|
||||
{t('settings.commands.page.section.template')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -304,7 +306,7 @@ export const CommandsPage: React.FC = () => {
|
||||
<Textarea
|
||||
value={template}
|
||||
onChange={(e) => setTemplate(e.target.value)}
|
||||
placeholder={`Your command template here...\n\nUse $ARGUMENTS to reference user input.\nUse !\`shell command\` to inject shell output.\nUse @filename to include file contents.`}
|
||||
placeholder={t('settings.commands.page.field.templatePlaceholder')}
|
||||
rows={12}
|
||||
className="w-full font-mono typography-meta min-h-[160px] max-h-[60vh] bg-transparent resize-y"
|
||||
/>
|
||||
@@ -312,9 +314,9 @@ export const CommandsPage: React.FC = () => {
|
||||
|
||||
<div className="mt-2 px-2">
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
<code className="text-foreground">$ARGUMENTS</code> user input ·{' '}
|
||||
<code className="text-foreground">!`cmd`</code> shell output ·{' '}
|
||||
<code className="text-foreground">@file</code> file contents
|
||||
<code className="text-foreground">$ARGUMENTS</code> {t('settings.commands.page.templateHint.userInput')} ·{' '}
|
||||
<code className="text-foreground">!`cmd`</code> {t('settings.commands.page.templateHint.shellOutput')} ·{' '}
|
||||
<code className="text-foreground">@file</code> {t('settings.commands.page.templateHint.fileContents')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -327,7 +329,7 @@ export const CommandsPage: React.FC = () => {
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
{isSaving ? t('settings.common.actions.saving') : t('settings.common.actions.saveChanges')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,12 +23,14 @@ import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface CommandsSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
}
|
||||
|
||||
export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const [renameDialogCommand, setRenameDialogCommand] = React.useState<Command | null>(null);
|
||||
const [renameNewName, setRenameNewName] = React.useState('');
|
||||
const [confirmActionCommand, setConfirmActionCommand] = React.useState<Command | null>(null);
|
||||
@@ -90,7 +92,7 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
|
||||
const handleDeleteCommand = async (command: Command) => {
|
||||
if (isCommandBuiltIn(command)) {
|
||||
toast.error('Built-in commands cannot be deleted');
|
||||
toast.error(t('settings.commands.sidebar.toast.builtInCannotDelete'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -122,15 +124,15 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
|
||||
if (success) {
|
||||
if (confirmActionType === 'delete') {
|
||||
toast.success(`Command "${confirmActionCommand.name}" deleted successfully`);
|
||||
toast.success(t('settings.commands.sidebar.toast.commandDeleted', { name: confirmActionCommand.name }));
|
||||
} else {
|
||||
toast.success(`Command "${confirmActionCommand.name}" reset to default`);
|
||||
toast.success(t('settings.commands.sidebar.toast.commandReset', { name: confirmActionCommand.name }));
|
||||
}
|
||||
closeConfirmActionDialog();
|
||||
} else if (confirmActionType === 'delete') {
|
||||
toast.error('Failed to delete command');
|
||||
toast.error(t('settings.commands.sidebar.toast.deleteFailed'));
|
||||
} else {
|
||||
toast.error('Failed to reset command');
|
||||
toast.error(t('settings.commands.sidebar.toast.resetFailed'));
|
||||
}
|
||||
|
||||
setIsConfirmActionPending(false);
|
||||
@@ -171,7 +173,7 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
const sanitizedName = renameNewName.trim().replace(/\s+/g, '-');
|
||||
|
||||
if (!sanitizedName) {
|
||||
toast.error('Command name is required');
|
||||
toast.error(t('settings.commands.sidebar.toast.commandNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -181,7 +183,7 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
}
|
||||
|
||||
if (commands.some((cmd) => cmd.name === sanitizedName)) {
|
||||
toast.error('A command with this name already exists');
|
||||
toast.error(t('settings.commands.sidebar.toast.commandExists'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -201,10 +203,10 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
toast.success(`Command renamed to "${sanitizedName}"`);
|
||||
setSelectedCommand(sanitizedName);
|
||||
} else {
|
||||
toast.error('Failed to remove old command after rename');
|
||||
toast.error(t('settings.commands.sidebar.toast.removeOldAfterRenameFailed'));
|
||||
}
|
||||
} else {
|
||||
toast.error('Failed to rename command');
|
||||
toast.error(t('settings.commands.sidebar.toast.renameFailed'));
|
||||
}
|
||||
|
||||
setRenameDialogCommand(null);
|
||||
@@ -216,10 +218,10 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">Commands</h2>
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.commands.sidebar.title')}</h2>
|
||||
<SettingsProjectSelector className="mb-3" />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">Total {commandOnlyItems.length}</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.commands.sidebar.total', { count: commandOnlyItems.length })}</span>
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
|
||||
@@ -234,15 +236,15 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
{commandOnlyItems.length === 0 ? (
|
||||
<div className="py-12 px-4 text-center text-muted-foreground">
|
||||
<RiTerminalBoxLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
||||
<p className="typography-ui-label font-medium">No commands configured</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Use the + button above to create one</p>
|
||||
<p className="typography-ui-label font-medium">{t('settings.commands.sidebar.empty.title')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.commands.sidebar.empty.description')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{builtInCommands.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Built-in Commands
|
||||
{t('settings.commands.sidebar.section.builtIn')}
|
||||
</div>
|
||||
{[...builtInCommands].sort((a, b) => a.name.localeCompare(b.name)).map((command) => (
|
||||
<CommandListItem
|
||||
@@ -266,7 +268,7 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
{customCommands.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pb-1.5 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Custom Commands
|
||||
{t('settings.commands.sidebar.section.custom')}
|
||||
</div>
|
||||
{[...customCommands].sort((a, b) => a.name.localeCompare(b.name)).map((command) => (
|
||||
<CommandListItem
|
||||
@@ -301,11 +303,11 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{confirmActionType === 'delete' ? 'Delete Command' : 'Reset Command'}</DialogTitle>
|
||||
<DialogTitle>{confirmActionType === 'delete' ? t('settings.commands.sidebar.dialog.deleteTitle') : t('settings.commands.sidebar.dialog.resetTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{confirmActionType === 'delete'
|
||||
? `Are you sure you want to delete command "${confirmActionCommand?.name}"?`
|
||||
: `Are you sure you want to reset command "${confirmActionCommand?.name}" to its default configuration?`}
|
||||
? t('settings.commands.sidebar.dialog.deleteDescription', { name: confirmActionCommand?.name ?? '' })
|
||||
: t('settings.commands.sidebar.dialog.resetDescription', { name: confirmActionCommand?.name ?? '' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -315,10 +317,10 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
onClick={closeConfirmActionDialog}
|
||||
disabled={isConfirmActionPending}
|
||||
>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleConfirmAction} disabled={isConfirmActionPending}>
|
||||
{confirmActionType === 'delete' ? 'Delete' : 'Reset'}
|
||||
{confirmActionType === 'delete' ? t('settings.common.actions.delete') : t('settings.common.actions.reset')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -328,15 +330,15 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
<Dialog open={renameDialogCommand !== null} onOpenChange={(open) => !open && setRenameDialogCommand(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename Command</DialogTitle>
|
||||
<DialogTitle>{t('settings.commands.sidebar.renameDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter a new name for the command "/{renameDialogCommand?.name}"
|
||||
{t('settings.commands.sidebar.renameDialog.description', { name: renameDialogCommand?.name ?? '' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={renameNewName}
|
||||
onChange={(e) => setRenameNewName(e.target.value)}
|
||||
placeholder="New command name..."
|
||||
placeholder={t('settings.commands.sidebar.renameDialog.placeholder')}
|
||||
className="text-foreground placeholder:text-muted-foreground"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
@@ -350,10 +352,10 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
variant="ghost"
|
||||
onClick={() => setRenameDialogCommand(null)}
|
||||
>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleRenameCommand}>
|
||||
Rename
|
||||
{t('settings.common.actions.rename')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -385,6 +387,7 @@ const CommandListItem: React.FC<CommandListItemProps> = ({
|
||||
isMenuOpen,
|
||||
onMenuOpenChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isMobile = isMobileDeviceViaCSS();
|
||||
return (
|
||||
<div
|
||||
@@ -409,7 +412,7 @@ const CommandListItem: React.FC<CommandListItemProps> = ({
|
||||
</span>
|
||||
{(command.scope || isCommandBuiltIn(command)) && (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
{isCommandBuiltIn(command) ? 'system' : command.scope}
|
||||
{isCommandBuiltIn(command) ? t('settings.agents.sidebar.badge.system') : command.scope}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -439,7 +442,7 @@ const CommandListItem: React.FC<CommandListItemProps> = ({
|
||||
}}
|
||||
>
|
||||
<RiEditLine className="h-4 w-4 mr-px" />
|
||||
Rename
|
||||
{t('settings.common.actions.rename')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
@@ -450,7 +453,7 @@ const CommandListItem: React.FC<CommandListItemProps> = ({
|
||||
}}
|
||||
>
|
||||
<RiFileCopyLine className="h-4 w-4 mr-px" />
|
||||
Duplicate
|
||||
{t('settings.common.actions.duplicate')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{onReset && (
|
||||
@@ -461,7 +464,7 @@ const CommandListItem: React.FC<CommandListItemProps> = ({
|
||||
}}
|
||||
>
|
||||
<RiRestartLine className="h-4 w-4 mr-px" />
|
||||
Reset
|
||||
{t('settings.common.actions.reset')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
@@ -474,7 +477,7 @@ const CommandListItem: React.FC<CommandListItemProps> = ({
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-px" />
|
||||
Delete
|
||||
{t('settings.common.actions.delete')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
RiLock2Line,
|
||||
} from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const PROFILE_COLORS = [
|
||||
{ key: 'keyword', label: 'Green', cssVar: 'var(--syntax-keyword)' },
|
||||
@@ -56,6 +57,7 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
profileId,
|
||||
importData,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
getProfileById,
|
||||
createProfile,
|
||||
@@ -130,11 +132,11 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!userName.trim() || !userEmail.trim()) {
|
||||
toast.error('User name and email are required');
|
||||
toast.error(t('settings.gitIdentities.editor.toast.userNameEmailRequired'));
|
||||
return;
|
||||
}
|
||||
if (authType === 'token' && !host.trim()) {
|
||||
toast.error('Host is required for token-based authentication');
|
||||
toast.error(t('settings.gitIdentities.editor.toast.hostRequiredForToken'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -161,14 +163,14 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
}
|
||||
|
||||
if (success) {
|
||||
toast.success(isNewProfile ? 'Profile created' : 'Profile updated');
|
||||
toast.success(isNewProfile ? t('settings.gitIdentities.editor.toast.profileCreated') : t('settings.gitIdentities.editor.toast.profileUpdated'));
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.error(isNewProfile ? 'Failed to create profile' : 'Failed to update profile');
|
||||
toast.error(isNewProfile ? t('settings.gitIdentities.editor.toast.createProfileFailed') : t('settings.gitIdentities.editor.toast.updateProfileFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving profile:', error);
|
||||
toast.error('An error occurred while saving');
|
||||
toast.error(t('settings.gitIdentities.editor.toast.saveUnexpectedError'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -180,15 +182,15 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
try {
|
||||
const success = await deleteProfile(profileId);
|
||||
if (success) {
|
||||
toast.success('Profile deleted');
|
||||
toast.success(t('settings.gitIdentities.editor.toast.profileDeleted'));
|
||||
setIsDeleteDialogOpen(false);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.error('Failed to delete profile');
|
||||
toast.error(t('settings.gitIdentities.editor.toast.deleteProfileFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting profile:', error);
|
||||
toast.error('An error occurred while deleting');
|
||||
toast.error(t('settings.gitIdentities.editor.toast.deleteUnexpectedError'));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
@@ -200,12 +202,12 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
}, [color]);
|
||||
|
||||
const title = importData
|
||||
? 'Import Credential'
|
||||
? t('settings.gitIdentities.editor.title.importCredential')
|
||||
: isNewProfile
|
||||
? 'New Identity'
|
||||
? t('settings.gitIdentities.editor.title.newIdentity')
|
||||
: isGlobalProfile
|
||||
? 'Global Identity'
|
||||
: (selectedProfile?.name || 'Edit Identity');
|
||||
? t('settings.gitIdentities.editor.title.globalIdentity')
|
||||
: (selectedProfile?.name || t('settings.gitIdentities.editor.title.editIdentity'));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -215,10 +217,10 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isGlobalProfile
|
||||
? 'System-wide Git identity (read-only)'
|
||||
? t('settings.gitIdentities.editor.description.globalReadOnly')
|
||||
: isNewProfile
|
||||
? 'Create a new Git identity profile'
|
||||
: 'Edit identity profile settings'}
|
||||
? t('settings.gitIdentities.editor.description.newProfile')
|
||||
: t('settings.gitIdentities.editor.description.editProfile')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -227,17 +229,17 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
{!isGlobalProfile && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="typography-ui-label text-foreground block mb-1.5">Profile Name</label>
|
||||
<label className="typography-ui-label text-foreground block mb-1.5">{t('settings.gitIdentities.editor.field.profileName')}</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Work Profile, Personal, etc."
|
||||
placeholder={t('settings.gitIdentities.editor.field.profileNamePlaceholder')}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="typography-ui-label text-foreground">Color</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.gitIdentities.editor.field.color')}</span>
|
||||
<div className="flex gap-1.5">
|
||||
{PROFILE_COLORS.map((c) => (
|
||||
<button
|
||||
@@ -258,7 +260,7 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="typography-ui-label text-foreground">Icon</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.gitIdentities.editor.field.icon')}</span>
|
||||
<div className="flex gap-1.5">
|
||||
{PROFILE_ICONS.map((i) => {
|
||||
const IconComponent = i.Icon;
|
||||
@@ -294,21 +296,21 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<label className="typography-ui-label text-foreground">User Name</label>
|
||||
<label className="typography-ui-label text-foreground">{t('settings.gitIdentities.editor.field.userName')}</label>
|
||||
{!isGlobalProfile && <span className="text-[var(--status-error)] text-xs">*</span>}
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
The name that will appear in Git commit messages.
|
||||
{t('settings.gitIdentities.editor.field.userNameTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
value={userName}
|
||||
onChange={(e) => setUserName(e.target.value)}
|
||||
placeholder="John Doe"
|
||||
placeholder={t('settings.gitIdentities.editor.field.userNamePlaceholder')}
|
||||
required={!isGlobalProfile}
|
||||
readOnly={isGlobalProfile}
|
||||
disabled={isGlobalProfile}
|
||||
@@ -318,14 +320,14 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<label className="typography-ui-label text-foreground">Email Address</label>
|
||||
<label className="typography-ui-label text-foreground">{t('settings.gitIdentities.editor.field.emailAddress')}</label>
|
||||
{!isGlobalProfile && <span className="text-[var(--status-error)] text-xs">*</span>}
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Should match your email in GitHub/GitLab for proper attribution.
|
||||
{t('settings.gitIdentities.editor.field.emailAddressTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -333,7 +335,7 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
type="email"
|
||||
value={userEmail}
|
||||
onChange={(e) => setUserEmail(e.target.value)}
|
||||
placeholder="john@example.com"
|
||||
placeholder={t('settings.gitIdentities.editor.field.emailAddressPlaceholder')}
|
||||
required={!isGlobalProfile}
|
||||
readOnly={isGlobalProfile}
|
||||
disabled={isGlobalProfile}
|
||||
@@ -348,7 +350,7 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
<div className="border-t border-border/40" />
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="typography-ui-label text-foreground">Auth Method</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.gitIdentities.editor.field.authMethod')}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm"
|
||||
type="button"
|
||||
@@ -364,7 +366,7 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
aria-pressed={authType === 'token'}
|
||||
onClick={() => setAuthType('token')}
|
||||
>
|
||||
<RiKeyLine className="w-3.5 h-3.5 mr-1" /> Token
|
||||
<RiKeyLine className="w-3.5 h-3.5 mr-1" /> {t('settings.gitIdentities.editor.field.authToken')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -372,20 +374,20 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
{authType === 'ssh' && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<label className="typography-ui-label text-foreground">SSH Key Path</label>
|
||||
<label className="typography-ui-label text-foreground">{t('settings.gitIdentities.editor.field.sshKeyPath')}</label>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Optional path to private key. e.g. ~/.ssh/id_ed25519
|
||||
{t('settings.gitIdentities.editor.field.sshKeyPathTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
value={sshKey}
|
||||
onChange={(e) => setSshKey(e.target.value)}
|
||||
placeholder="~/.ssh/id_ed25519"
|
||||
placeholder={t('settings.gitIdentities.editor.field.sshKeyPathPlaceholder')}
|
||||
className="h-8 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
@@ -394,21 +396,21 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
{authType === 'token' && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<label className="typography-ui-label text-foreground">Host</label>
|
||||
<label className="typography-ui-label text-foreground">{t('settings.gitIdentities.editor.field.host')}</label>
|
||||
<span className="text-[var(--status-error)] text-xs">*</span>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Token will be read from ~/.git-credentials for this host.
|
||||
{t('settings.gitIdentities.editor.field.hostTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
placeholder="github.com"
|
||||
placeholder={t('settings.gitIdentities.editor.field.hostPlaceholder')}
|
||||
required
|
||||
className="h-8 font-mono text-xs"
|
||||
/>
|
||||
@@ -427,15 +429,15 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
onClick={() => setIsDeleteDialogOpen(true)}
|
||||
className="text-[var(--status-error)] hover:text-[var(--status-error)] border-[var(--status-error)]/30 hover:bg-[var(--status-error)]/10 mr-auto"
|
||||
>
|
||||
<RiDeleteBinLine className="w-3.5 h-3.5 mr-1" /> Delete
|
||||
<RiDeleteBinLine className="w-3.5 h-3.5 mr-1" /> {t('settings.common.actions.delete')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)} className="text-foreground hover:bg-interactive-hover hover:text-foreground">
|
||||
{isGlobalProfile ? 'Close' : 'Cancel'}
|
||||
{isGlobalProfile ? t('settings.gitIdentities.editor.actions.close') : t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
{!isGlobalProfile && (
|
||||
<Button size="sm" onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? 'Saving...' : isNewProfile ? 'Create' : 'Save'}
|
||||
{isSaving ? t('settings.common.actions.saving') : isNewProfile ? t('settings.gitIdentities.editor.actions.create') : t('settings.gitIdentities.editor.actions.save')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
@@ -449,17 +451,17 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Profile</DialogTitle>
|
||||
<DialogTitle>{t('settings.gitIdentities.page.deleteDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{selectedProfile?.name || name}"?
|
||||
{t('settings.gitIdentities.page.deleteDialog.description', { name: selectedProfile?.name || name })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setIsDeleteDialogOpen(false)} disabled={isDeleting}>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => void handleConfirmDelete()} disabled={isDeleting}>
|
||||
Delete
|
||||
{t('settings.common.actions.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -34,6 +34,7 @@ import { GitHubSettings } from '@/components/sections/openchamber/GitHubSettings
|
||||
import { GitIdentityEditorDialog } from './GitIdentityEditorDialog';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const ICON_MAP: Record<string, React.ComponentType<{ className?: string; style?: React.CSSProperties }>> = {
|
||||
branch: RiGitBranchLine,
|
||||
@@ -53,6 +54,7 @@ const COLOR_MAP: Record<string, string> = {
|
||||
};
|
||||
|
||||
export const GitPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
profiles,
|
||||
globalIdentity,
|
||||
@@ -91,10 +93,10 @@ export const GitPage: React.FC = () => {
|
||||
const next = defaultGitIdentityId === profileId ? null : profileId;
|
||||
const ok = await setDefaultGitIdentityId(next);
|
||||
if (!ok) {
|
||||
toast.error('Failed to update default identity');
|
||||
toast.error(t('settings.gitIdentities.page.toast.updateDefaultFailed'));
|
||||
return;
|
||||
}
|
||||
toast.success(next ? 'Default identity updated' : 'Default identity unset');
|
||||
toast.success(next ? t('settings.gitIdentities.page.toast.defaultUpdated') : t('settings.gitIdentities.page.toast.defaultUnset'));
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
@@ -102,10 +104,10 @@ export const GitPage: React.FC = () => {
|
||||
setIsDeletePending(true);
|
||||
const success = await deleteProfile(deleteDialogProfile.id);
|
||||
if (success) {
|
||||
toast.success(`Profile "${deleteDialogProfile.name}" deleted`);
|
||||
toast.success(t('settings.gitIdentities.page.toast.profileDeleted', { name: deleteDialogProfile.name }));
|
||||
setDeleteDialogProfile(null);
|
||||
} else {
|
||||
toast.error('Failed to delete profile');
|
||||
toast.error(t('settings.gitIdentities.page.toast.deleteProfileFailed'));
|
||||
}
|
||||
setIsDeletePending(false);
|
||||
};
|
||||
@@ -120,10 +122,10 @@ export const GitPage: React.FC = () => {
|
||||
<div className="border-t border-border/40 pt-6">
|
||||
<div className="mb-3 px-1 flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">Identities</h3>
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">{t('settings.gitIdentities.page.section.title')}</h3>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => openEditor('new')}>
|
||||
<RiAddLine className="w-3.5 h-3.5 mr-1" /> New
|
||||
<RiAddLine className="w-3.5 h-3.5 mr-1" /> {t('settings.common.badge.new')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -157,8 +159,8 @@ export const GitPage: React.FC = () => {
|
||||
{!globalIdentity && profiles.length === 0 && unimportedCredentials.length === 0 && (
|
||||
<div className="py-8 px-4 text-center text-muted-foreground">
|
||||
<RiShieldKeyholeLine className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||
<p className="typography-ui-label">No identities configured</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Create one to manage Git author settings per project</p>
|
||||
<p className="typography-ui-label">{t('settings.gitIdentities.page.empty.title')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.gitIdentities.page.empty.description')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -167,7 +169,7 @@ export const GitPage: React.FC = () => {
|
||||
<>
|
||||
<div className="px-4 py-2 border-t border-[var(--surface-subtle)]">
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
Found in ~/.git-credentials
|
||||
{t('settings.gitIdentities.page.discoveredCredentials.title')}
|
||||
</span>
|
||||
</div>
|
||||
{unimportedCredentials.map((cred, i) => (
|
||||
@@ -202,17 +204,17 @@ export const GitPage: React.FC = () => {
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Profile</DialogTitle>
|
||||
<DialogTitle>{t('settings.gitIdentities.page.deleteDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{deleteDialogProfile?.name}"?
|
||||
{t('settings.gitIdentities.page.deleteDialog.description', { name: deleteDialogProfile?.name ?? '' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setDeleteDialogProfile(null)} disabled={isDeletePending}>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => void handleConfirmDelete()} disabled={isDeletePending}>
|
||||
Delete
|
||||
{t('settings.common.actions.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -242,6 +244,7 @@ const IdentityRow: React.FC<IdentityRowProps> = ({
|
||||
isReadOnly,
|
||||
hasBorder,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const IconComponent = ICON_MAP[profile.icon || 'branch'] || RiGitBranchLine;
|
||||
const iconColor = COLOR_MAP[profile.color || ''];
|
||||
const authType = profile.authType || 'ssh';
|
||||
@@ -267,12 +270,12 @@ const IdentityRow: React.FC<IdentityRowProps> = ({
|
||||
</span>
|
||||
{isDefault && (
|
||||
<span className="typography-micro text-primary bg-primary/12 px-1 rounded flex-shrink-0 leading-none pb-px border border-primary/25">
|
||||
default
|
||||
{t('settings.gitIdentities.page.badge.default')}
|
||||
</span>
|
||||
)}
|
||||
{isReadOnly && (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
system
|
||||
{t('settings.agents.sidebar.badge.system')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -295,7 +298,7 @@ const IdentityRow: React.FC<IdentityRowProps> = ({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-fit min-w-28">
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onToggleDefault(); }}>
|
||||
{isDefault ? 'Unset default' : 'Set as default'}
|
||||
{isDefault ? t('settings.gitIdentities.page.actions.unsetDefault') : t('settings.gitIdentities.page.actions.setAsDefault')}
|
||||
</DropdownMenuItem>
|
||||
{!isReadOnly && onDelete && (
|
||||
<DropdownMenuItem
|
||||
@@ -303,7 +306,7 @@ const IdentityRow: React.FC<IdentityRowProps> = ({
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-px" />
|
||||
Delete
|
||||
{t('settings.common.actions.delete')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
@@ -321,6 +324,7 @@ interface DiscoveredRowProps {
|
||||
}
|
||||
|
||||
const DiscoveredRow: React.FC<DiscoveredRowProps> = ({ credential, onImport, hasBorder }) => {
|
||||
const { t } = useI18n();
|
||||
const parts = credential.host.split('/');
|
||||
const displayName = parts.length >= 3 ? parts[parts.length - 1] : credential.host;
|
||||
const isRepoSpecific = credential.host.includes('/');
|
||||
@@ -340,7 +344,7 @@ const DiscoveredRow: React.FC<DiscoveredRowProps> = ({ credential, onImport, has
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" onClick={onImport} className="gap-1 shrink-0">
|
||||
<RiDownloadLine className="h-3 w-3" />
|
||||
Import
|
||||
{t('settings.gitIdentities.page.actions.import')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,129 +14,130 @@ import {
|
||||
type MagicPromptId,
|
||||
} from '@/lib/magicPrompts';
|
||||
import { useMagicPromptsStore } from '@/stores/useMagicPromptsStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type PromptBlock = {
|
||||
id: MagicPromptId;
|
||||
title: string;
|
||||
titleKey: string;
|
||||
};
|
||||
|
||||
type PromptPageConfig = {
|
||||
title: string;
|
||||
description: string;
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
blocks: PromptBlock[];
|
||||
};
|
||||
|
||||
const PROMPT_PAGE_MAP: Record<string, PromptPageConfig> = {
|
||||
'git.commit.generate': {
|
||||
title: 'Commit Generation',
|
||||
description: 'Prompts used for commit message generation: visible user message + hidden instructions.',
|
||||
titleKey: 'settings.magicPrompts.page.group.gitCommitGenerate.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.gitCommitGenerate.description',
|
||||
blocks: [
|
||||
{ id: 'git.commit.generate.visible', title: 'Visible Prompt' },
|
||||
{ id: 'git.commit.generate.instructions', title: 'Instructions' },
|
||||
{ id: 'git.commit.generate.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'git.commit.generate.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'git.pr.generate': {
|
||||
title: 'PR Generation',
|
||||
description: 'Prompts used for PR title/body generation: visible user message + hidden instructions.',
|
||||
titleKey: 'settings.magicPrompts.page.group.gitPrGenerate.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.gitPrGenerate.description',
|
||||
blocks: [
|
||||
{ id: 'git.pr.generate.visible', title: 'Visible Prompt' },
|
||||
{ id: 'git.pr.generate.instructions', title: 'Instructions' },
|
||||
{ id: 'git.pr.generate.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'git.pr.generate.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'github.pr.review': {
|
||||
title: 'PR Review',
|
||||
description: 'Prompts used for PR review flow: visible user message + hidden instruction payload.',
|
||||
titleKey: 'settings.magicPrompts.page.group.githubPrReview.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.githubPrReview.description',
|
||||
blocks: [
|
||||
{ id: 'github.pr.review.visible', title: 'Visible Prompt' },
|
||||
{ id: 'github.pr.review.instructions', title: 'Instructions' },
|
||||
{ id: 'github.pr.review.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'github.pr.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'github.issue.review': {
|
||||
title: 'Issue Review',
|
||||
description: 'Prompts used for issue review flow: visible user message + hidden instruction payload.',
|
||||
titleKey: 'settings.magicPrompts.page.group.githubIssueReview.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.githubIssueReview.description',
|
||||
blocks: [
|
||||
{ id: 'github.issue.review.visible', title: 'Visible Prompt' },
|
||||
{ id: 'github.issue.review.instructions', title: 'Instructions' },
|
||||
{ id: 'github.issue.review.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'github.issue.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'github.pr.checks.review': {
|
||||
title: 'PR Failed Checks Review',
|
||||
description: 'Prompts used for PR failed checks analysis.',
|
||||
titleKey: 'settings.magicPrompts.page.group.githubPrFailedChecksReview.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.githubPrFailedChecksReview.description',
|
||||
blocks: [
|
||||
{ id: 'github.pr.checks.review.visible', title: 'Visible Prompt' },
|
||||
{ id: 'github.pr.checks.review.instructions', title: 'Instructions' },
|
||||
{ id: 'github.pr.checks.review.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'github.pr.checks.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'github.pr.comments.review': {
|
||||
title: 'PR Comments Review',
|
||||
description: 'Prompts used for PR comments analysis.',
|
||||
titleKey: 'settings.magicPrompts.page.group.githubPrCommentsReview.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.githubPrCommentsReview.description',
|
||||
blocks: [
|
||||
{ id: 'github.pr.comments.review.visible', title: 'Visible Prompt' },
|
||||
{ id: 'github.pr.comments.review.instructions', title: 'Instructions' },
|
||||
{ id: 'github.pr.comments.review.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'github.pr.comments.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'github.pr.comment.single': {
|
||||
title: 'Single PR Comment Review',
|
||||
description: 'Prompts used for single PR comment analysis.',
|
||||
titleKey: 'settings.magicPrompts.page.group.githubSinglePrCommentReview.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.githubSinglePrCommentReview.description',
|
||||
blocks: [
|
||||
{ id: 'github.pr.comment.single.visible', title: 'Visible Prompt' },
|
||||
{ id: 'github.pr.comment.single.instructions', title: 'Instructions' },
|
||||
{ id: 'github.pr.comment.single.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'github.pr.comment.single.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'git.conflict.resolve': {
|
||||
title: 'Merge/Rebase Conflict Resolution',
|
||||
description: 'Prompts used when resolving merge/rebase conflicts with AI.',
|
||||
titleKey: 'settings.magicPrompts.page.group.gitConflictResolve.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.gitConflictResolve.description',
|
||||
blocks: [
|
||||
{ id: 'git.conflict.resolve.visible', title: 'Visible Prompt' },
|
||||
{ id: 'git.conflict.resolve.instructions', title: 'Instructions' },
|
||||
{ id: 'git.conflict.resolve.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'git.conflict.resolve.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'git.integrate.cherrypick.resolve': {
|
||||
title: 'Cherry-pick Conflict Resolution',
|
||||
description: 'Prompts used when resolving cherry-pick conflicts in integrate flow.',
|
||||
titleKey: 'settings.magicPrompts.page.group.gitCherrypickConflictResolve.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.gitCherrypickConflictResolve.description',
|
||||
blocks: [
|
||||
{ id: 'git.integrate.cherrypick.resolve.visible', title: 'Visible Prompt' },
|
||||
{ id: 'git.integrate.cherrypick.resolve.instructions', title: 'Instructions' },
|
||||
{ id: 'git.integrate.cherrypick.resolve.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'git.integrate.cherrypick.resolve.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'plan.improve': {
|
||||
title: 'Improve Plan',
|
||||
description: 'Hidden prompt used when sending a saved plan into an improve flow.',
|
||||
titleKey: 'settings.magicPrompts.page.group.planImprove.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.planImprove.description',
|
||||
blocks: [
|
||||
{ id: 'plan.improve.visible', title: 'Visible Prompt' },
|
||||
{ id: 'plan.improve.instructions', title: 'Instructions' },
|
||||
{ id: 'plan.improve.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'plan.improve.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'plan.todo': {
|
||||
title: 'Todo Planning',
|
||||
description: 'Hidden prompt used when sending a todo into a new planning session.',
|
||||
titleKey: 'settings.magicPrompts.page.group.planTodo.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.planTodo.description',
|
||||
blocks: [
|
||||
{ id: 'plan.todo.visible', title: 'Visible Prompt' },
|
||||
{ id: 'plan.todo.instructions', title: 'Instructions' },
|
||||
{ id: 'plan.todo.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'plan.todo.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'plan.implement': {
|
||||
title: 'Implement Plan',
|
||||
description: 'Hidden prompt used when sending a saved plan into an implement flow.',
|
||||
titleKey: 'settings.magicPrompts.page.group.planImplement.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.planImplement.description',
|
||||
blocks: [
|
||||
{ id: 'plan.implement.visible', title: 'Visible Prompt' },
|
||||
{ id: 'plan.implement.instructions', title: 'Instructions' },
|
||||
{ id: 'plan.implement.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'plan.implement.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'session.summary': {
|
||||
title: 'Session Summary',
|
||||
description: 'Prompts used by the /summary slash command: visible user message + hidden instructions. Non-destructive — does not compact session history.',
|
||||
titleKey: 'settings.magicPrompts.page.group.sessionSummary.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.sessionSummary.description',
|
||||
blocks: [
|
||||
{ id: 'session.summary.visible', title: 'Visible Prompt' },
|
||||
{ id: 'session.summary.instructions', title: 'Instructions' },
|
||||
{ id: 'session.summary.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'session.summary.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'session.review': {
|
||||
title: 'Workspace Review',
|
||||
description: 'Prompts used by the /review slash command: visible user message + hidden instructions. Reviews current workspace changes for high-signal issues only.',
|
||||
titleKey: 'settings.magicPrompts.page.group.sessionWorkspaceReview.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.sessionWorkspaceReview.description',
|
||||
blocks: [
|
||||
{ id: 'session.review.visible', title: 'Visible Prompt' },
|
||||
{ id: 'session.review.instructions', title: 'Instructions' },
|
||||
{ id: 'session.review.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'session.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -145,6 +146,8 @@ const hasOwn = (input: Record<string, string>, key: string) => Object.prototype.
|
||||
const isVisiblePromptId = (id: MagicPromptId): boolean => id.endsWith('.visible');
|
||||
|
||||
export const MagicPromptsPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const tUnsafe = React.useCallback((key: string) => t(key as Parameters<typeof t>[0]), [t]);
|
||||
const selectedPromptId = useMagicPromptsStore((state) => state.selectedPromptId);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [overrides, setOverrides] = React.useState<Record<string, string>>({});
|
||||
@@ -163,7 +166,7 @@ export const MagicPromptsPage: React.FC = () => {
|
||||
setOverrides(nextOverrides);
|
||||
} catch (error) {
|
||||
console.warn('Failed to load magic prompts:', error);
|
||||
toast.error('Failed to load Magic Prompts');
|
||||
toast.error(t('settings.magicPrompts.page.toast.loadFailed'));
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
@@ -174,7 +177,7 @@ export const MagicPromptsPage: React.FC = () => {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const pageConfig = PROMPT_PAGE_MAP[selectedPromptId] ?? PROMPT_PAGE_MAP['git.commit.generate'];
|
||||
const getBaseline = React.useCallback((id: MagicPromptId) => {
|
||||
@@ -197,7 +200,7 @@ export const MagicPromptsPage: React.FC = () => {
|
||||
const savePrompt = React.useCallback(async (id: MagicPromptId) => {
|
||||
const value = getDraft(id);
|
||||
if (isVisiblePromptId(id) && value.trim().length === 0) {
|
||||
toast.error('Visible prompt cannot be empty');
|
||||
toast.error(t('settings.magicPrompts.page.toast.visiblePromptRequired'));
|
||||
return;
|
||||
}
|
||||
setSavingIds((current) => ({ ...current, [id]: true }));
|
||||
@@ -206,14 +209,14 @@ export const MagicPromptsPage: React.FC = () => {
|
||||
? await resetMagicPromptOverride(id)
|
||||
: await saveMagicPromptOverride(id, value);
|
||||
setOverrides(payload.overrides);
|
||||
toast.success('Magic prompt saved');
|
||||
toast.success(t('settings.magicPrompts.page.toast.saved'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.error('Failed to save magic prompt', { description: message });
|
||||
toast.error(t('settings.magicPrompts.page.toast.saveFailed'), { description: message });
|
||||
} finally {
|
||||
setSavingIds((current) => ({ ...current, [id]: false }));
|
||||
}
|
||||
}, [getDraft]);
|
||||
}, [getDraft, t]);
|
||||
|
||||
const resetPrompt = React.useCallback(async (id: MagicPromptId) => {
|
||||
setResettingIds((current) => ({ ...current, [id]: true }));
|
||||
@@ -224,14 +227,14 @@ export const MagicPromptsPage: React.FC = () => {
|
||||
...current,
|
||||
[id]: getDefaultMagicPromptTemplate(id),
|
||||
}));
|
||||
toast.success('Prompt reset to default');
|
||||
toast.success(t('settings.magicPrompts.page.toast.resetSuccess'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.error('Failed to reset prompt', { description: message });
|
||||
toast.error(t('settings.magicPrompts.page.toast.resetFailed'), { description: message });
|
||||
} finally {
|
||||
setResettingIds((current) => ({ ...current, [id]: false }));
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleResetAll = React.useCallback(async () => {
|
||||
setResettingAll(true);
|
||||
@@ -239,20 +242,20 @@ export const MagicPromptsPage: React.FC = () => {
|
||||
const payload = await resetAllMagicPromptOverrides();
|
||||
setOverrides(payload.overrides);
|
||||
setDrafts({});
|
||||
toast.success('All prompt overrides reset');
|
||||
toast.success(t('settings.magicPrompts.page.toast.resetAllSuccess'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.error('Failed to reset all prompts', { description: message });
|
||||
toast.error(t('settings.magicPrompts.page.toast.resetAllFailed'), { description: message });
|
||||
} finally {
|
||||
setResettingAll(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-6 px-6 flex items-center gap-2 text-muted-foreground">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-current animate-busy-pulse" aria-label="Loading" />
|
||||
<span className="typography-ui">Loading Magic Prompts...</span>
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-current animate-busy-pulse" aria-label={t('settings.magicPrompts.page.loading.aria')} />
|
||||
<span className="typography-ui">{t('settings.magicPrompts.page.loading.text')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -263,13 +266,13 @@ export const MagicPromptsPage: React.FC = () => {
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground">{pageConfig.title}</h2>
|
||||
<h2 className="typography-ui-header font-semibold text-foreground">{tUnsafe(pageConfig.titleKey)}</h2>
|
||||
<Tooltip delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
{pageConfig.description}
|
||||
{tUnsafe(pageConfig.descriptionKey)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -282,7 +285,7 @@ export const MagicPromptsPage: React.FC = () => {
|
||||
}}
|
||||
disabled={resettingAll || Object.keys(overrides).length === 0}
|
||||
>
|
||||
{resettingAll ? 'Resetting...' : 'Reset All Overrides'}
|
||||
{resettingAll ? t('settings.magicPrompts.page.actions.resetting') : t('settings.magicPrompts.page.actions.resetAllOverrides')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -300,7 +303,7 @@ export const MagicPromptsPage: React.FC = () => {
|
||||
<section key={block.id} className={index > 0 ? 'space-y-3 pt-5 border-t border-border' : 'space-y-3'}>
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="typography-ui-label text-foreground">{block.title}</h3>
|
||||
<h3 className="typography-ui-label text-foreground">{tUnsafe(block.titleKey)}</h3>
|
||||
<Tooltip delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
@@ -312,7 +315,8 @@ export const MagicPromptsPage: React.FC = () => {
|
||||
</div>
|
||||
{definition.placeholders && definition.placeholders.length > 0 && (
|
||||
<div className="typography-micro text-muted-foreground">
|
||||
Placeholders: {definition.placeholders.map((item) => `{{${item.key}}}`).join(', ')}
|
||||
{t('settings.magicPrompts.page.placeholdersLabel')}{' '}
|
||||
{definition.placeholders.map((item) => `{{${item.key}}}`).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -323,12 +327,16 @@ export const MagicPromptsPage: React.FC = () => {
|
||||
className="min-h-[220px] font-mono text-sm"
|
||||
/>
|
||||
{isInvalidEmptyVisiblePrompt && (
|
||||
<div className="typography-micro text-[var(--status-error)]">Visible prompt cannot be empty.</div>
|
||||
<div className="typography-micro text-[var(--status-error)]">{t('settings.magicPrompts.page.validation.visiblePromptRequired')}</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
{isDirty ? 'Unsaved changes' : isOverridden ? 'Using saved override' : 'Using built-in default'}
|
||||
{isDirty
|
||||
? t('settings.magicPrompts.page.status.unsavedChanges')
|
||||
: isOverridden
|
||||
? t('settings.magicPrompts.page.status.usingSavedOverride')
|
||||
: t('settings.magicPrompts.page.status.usingBuiltinDefault')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
@@ -339,7 +347,7 @@ export const MagicPromptsPage: React.FC = () => {
|
||||
}}
|
||||
disabled={!isOverridden || saving || resetting}
|
||||
>
|
||||
{resetting ? 'Resetting...' : 'Reset to Default'}
|
||||
{resetting ? t('settings.magicPrompts.page.actions.resetting') : t('settings.magicPrompts.page.actions.resetToDefault')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -348,7 +356,7 @@ export const MagicPromptsPage: React.FC = () => {
|
||||
}}
|
||||
disabled={!isDirty || saving || resetting || isInvalidEmptyVisiblePrompt}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
{saving ? t('settings.common.actions.saving') : t('settings.magicPrompts.page.actions.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,49 +2,51 @@ import React from 'react';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { useMagicPromptsStore } from '@/stores/useMagicPromptsStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface MagicPromptsSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
}
|
||||
|
||||
export const MagicPromptsSidebar: React.FC<MagicPromptsSidebarProps> = ({ onItemSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const selectedPromptId = useMagicPromptsStore((state) => state.selectedPromptId);
|
||||
const setSelectedPromptId = useMagicPromptsStore((state) => state.setSelectedPromptId);
|
||||
|
||||
const grouped = React.useMemo(() => {
|
||||
return [
|
||||
{
|
||||
group: 'Git',
|
||||
groupKey: 'settings.magicPrompts.sidebar.group.git',
|
||||
items: [
|
||||
{ id: 'git.commit.generate', title: 'Commit Generation' },
|
||||
{ id: 'git.pr.generate', title: 'PR Generation' },
|
||||
{ id: 'git.conflict.resolve', title: 'Merge/Rebase Conflict Resolution' },
|
||||
{ id: 'git.integrate.cherrypick.resolve', title: 'Cherry-pick Conflict Resolution' },
|
||||
{ id: 'git.commit.generate', titleKey: 'settings.magicPrompts.sidebar.item.gitCommitGenerate' },
|
||||
{ id: 'git.pr.generate', titleKey: 'settings.magicPrompts.sidebar.item.gitPrGenerate' },
|
||||
{ id: 'git.conflict.resolve', titleKey: 'settings.magicPrompts.sidebar.item.gitConflictResolve' },
|
||||
{ id: 'git.integrate.cherrypick.resolve', titleKey: 'settings.magicPrompts.sidebar.item.gitCherrypickConflictResolve' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'GitHub',
|
||||
groupKey: 'settings.magicPrompts.sidebar.group.github',
|
||||
items: [
|
||||
{ id: 'github.pr.review', title: 'PR Review' },
|
||||
{ id: 'github.issue.review', title: 'Issue Review' },
|
||||
{ id: 'github.pr.checks.review', title: 'PR Failed Checks Review' },
|
||||
{ id: 'github.pr.comments.review', title: 'PR Comments Review' },
|
||||
{ id: 'github.pr.comment.single', title: 'Single PR Comment Review' },
|
||||
{ id: 'github.pr.review', titleKey: 'settings.magicPrompts.sidebar.item.githubPrReview' },
|
||||
{ id: 'github.issue.review', titleKey: 'settings.magicPrompts.sidebar.item.githubIssueReview' },
|
||||
{ id: 'github.pr.checks.review', titleKey: 'settings.magicPrompts.sidebar.item.githubPrFailedChecksReview' },
|
||||
{ id: 'github.pr.comments.review', titleKey: 'settings.magicPrompts.sidebar.item.githubPrCommentsReview' },
|
||||
{ id: 'github.pr.comment.single', titleKey: 'settings.magicPrompts.sidebar.item.githubSinglePrCommentReview' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Planning',
|
||||
groupKey: 'settings.magicPrompts.sidebar.group.planning',
|
||||
items: [
|
||||
{ id: 'plan.todo', title: 'Todo Planning' },
|
||||
{ id: 'plan.improve', title: 'Improve Plan' },
|
||||
{ id: 'plan.implement', title: 'Implement Plan' },
|
||||
{ id: 'plan.todo', titleKey: 'settings.magicPrompts.sidebar.item.planTodo' },
|
||||
{ id: 'plan.improve', titleKey: 'settings.magicPrompts.sidebar.item.planImprove' },
|
||||
{ id: 'plan.implement', titleKey: 'settings.magicPrompts.sidebar.item.planImplement' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Session',
|
||||
groupKey: 'settings.magicPrompts.sidebar.group.session',
|
||||
items: [
|
||||
{ id: 'session.summary', title: 'Session Summary' },
|
||||
{ id: 'session.review', title: 'Workspace Review' },
|
||||
{ id: 'session.summary', titleKey: 'settings.magicPrompts.sidebar.item.sessionSummary' },
|
||||
{ id: 'session.review', titleKey: 'settings.magicPrompts.sidebar.item.sessionWorkspaceReview' },
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
@@ -53,14 +55,14 @@ export const MagicPromptsSidebar: React.FC<MagicPromptsSidebarProps> = ({ onItem
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background">
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<h2 className="text-base font-semibold text-foreground">Magic Prompts</h2>
|
||||
<p className="typography-meta mt-1 text-muted-foreground">Select a prompt template to edit.</p>
|
||||
<h2 className="text-base font-semibold text-foreground">{t('settings.magicPrompts.sidebar.title')}</h2>
|
||||
<p className="typography-meta mt-1 text-muted-foreground">{t('settings.magicPrompts.sidebar.description')}</p>
|
||||
</div>
|
||||
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-3 px-3 py-2 overflow-x-hidden">
|
||||
{grouped.map((group) => (
|
||||
<div key={group.group} className="space-y-1">
|
||||
<div className="typography-micro px-1 text-muted-foreground">{group.group}</div>
|
||||
<div key={group.groupKey} className="space-y-1">
|
||||
<div className="typography-micro px-1 text-muted-foreground">{t(group.groupKey)}</div>
|
||||
{group.items.map((item) => {
|
||||
const selected = selectedPromptId === item.id;
|
||||
return (
|
||||
@@ -76,7 +78,7 @@ export const MagicPromptsSidebar: React.FC<MagicPromptsSidebarProps> = ({ onItem
|
||||
selected ? 'bg-interactive-selection text-foreground' : 'text-foreground hover:bg-interactive-hover'
|
||||
)}
|
||||
>
|
||||
<span className="typography-ui-label truncate font-normal">{item.title}</span>
|
||||
<span className="typography-ui-label truncate font-normal">{t(item.titleKey)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface McpSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
@@ -59,6 +60,7 @@ const StatusDot: React.FC<{ tone: StatusTone; enabled: boolean }> = ({ tone, ena
|
||||
};
|
||||
|
||||
export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const bgClass = 'bg-background';
|
||||
|
||||
const { mcpServers, selectedMcpName, setSelectedMcp, setMcpDraft, loadMcpConfigs, deleteMcp } =
|
||||
@@ -143,13 +145,13 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
if (result.ok) {
|
||||
if (result.reloadFailed) {
|
||||
toast.warning(result.message || `MCP server "${deleteTarget.name}" deleted, but OpenCode reload failed`, {
|
||||
description: result.warning || 'Refresh the MCP list if the UI looks stale.',
|
||||
description: result.warning || t('settings.mcp.sidebar.toast.refreshListIfStale'),
|
||||
});
|
||||
} else {
|
||||
toast.success(result.message || `MCP server "${deleteTarget.name}" deleted`);
|
||||
toast.success(result.message || t('settings.mcp.sidebar.toast.serverDeleted', { name: deleteTarget.name }));
|
||||
}
|
||||
} else {
|
||||
toast.error('Failed to delete MCP server');
|
||||
toast.error(t('settings.mcp.sidebar.toast.deleteFailed'));
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
setIsDeleting(false);
|
||||
@@ -159,14 +161,14 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold text-foreground">MCP Servers</h2>
|
||||
<h2 className="text-base font-semibold text-foreground">{t('settings.mcp.sidebar.title')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
disabled={isRefreshingStatus}
|
||||
onClick={handleRefresh}
|
||||
aria-label="Refresh MCP status"
|
||||
title="Refresh MCP status"
|
||||
aria-label={t('settings.mcp.sidebar.actions.refreshStatusAria')}
|
||||
title={t('settings.mcp.sidebar.actions.refreshStatusTitle')}
|
||||
>
|
||||
<RiRefreshLine className={cn('h-4 w-4', isRefreshingStatus && 'animate-spin')} />
|
||||
</button>
|
||||
@@ -174,13 +176,13 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
<SettingsProjectSelector className="mb-3" />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
Total {mcpServers.length}
|
||||
{t('settings.mcp.sidebar.total', { count: mcpServers.length })}
|
||||
</span>
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
|
||||
onClick={handleCreateNew}
|
||||
title="Add MCP server"
|
||||
title={t('settings.mcp.sidebar.actions.addServerTitle')}
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -192,15 +194,15 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
{mcpServers.length === 0 ? (
|
||||
<div className="py-12 px-4 text-center text-muted-foreground">
|
||||
<RiPlugLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
||||
<p className="typography-ui-label font-medium">No MCP servers configured</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Use the + button above to add one</p>
|
||||
<p className="typography-ui-label font-medium">{t('settings.mcp.sidebar.empty.title')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.mcp.sidebar.empty.description')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{projectServers.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Project Servers
|
||||
{t('settings.mcp.sidebar.group.projectServers')}
|
||||
</div>
|
||||
{projectServers.map((server) => {
|
||||
const runtimeStatus = mcpStatus[server.name];
|
||||
@@ -231,7 +233,10 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot tone={tone} enabled={server.enabled} />
|
||||
<span className="typography-ui-label font-normal truncate text-foreground">{server.name}</span>
|
||||
<span title={server.type === 'local' ? 'Local server' : 'Remote server'}>
|
||||
<span title={server.type === 'local'
|
||||
? t('settings.mcp.sidebar.serverType.localTitle')
|
||||
: t('settings.mcp.sidebar.serverType.remoteTitle')}
|
||||
>
|
||||
{server.type === 'local' ? (
|
||||
<RiServerLine className="h-3 w-3 text-muted-foreground/60 flex-shrink-0" />
|
||||
) : (
|
||||
@@ -261,7 +266,7 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-px" />
|
||||
Delete
|
||||
{t('settings.common.actions.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -274,7 +279,7 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
{userServers.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
User Servers
|
||||
{t('settings.mcp.sidebar.group.userServers')}
|
||||
</div>
|
||||
{userServers.map((server) => {
|
||||
const runtimeStatus = mcpStatus[server.name];
|
||||
@@ -305,7 +310,10 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot tone={tone} enabled={server.enabled} />
|
||||
<span className="typography-ui-label font-normal truncate text-foreground">{server.name}</span>
|
||||
<span title={server.type === 'local' ? 'Local server' : 'Remote server'}>
|
||||
<span title={server.type === 'local'
|
||||
? t('settings.mcp.sidebar.serverType.localTitle')
|
||||
: t('settings.mcp.sidebar.serverType.remoteTitle')}
|
||||
>
|
||||
{server.type === 'local' ? (
|
||||
<RiServerLine className="h-3 w-3 text-muted-foreground/60 flex-shrink-0" />
|
||||
) : (
|
||||
@@ -335,7 +343,7 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-px" />
|
||||
Delete
|
||||
{t('settings.common.actions.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -353,14 +361,14 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => { if (!open && !isDeleting) setDeleteTarget(null); }}
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete MCP Server</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{deleteTarget?.name}"? This will remove it from{' '}
|
||||
<code className="text-foreground">opencode.json</code>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('settings.mcp.sidebar.deleteDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('settings.mcp.sidebar.deleteDialog.descriptionPrefix', { name: deleteTarget?.name || '' })}{' '}
|
||||
<code className="text-foreground">opencode.json</code>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -368,10 +376,10 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleDelete} disabled={isDeleting}>
|
||||
{isDeleting ? 'Deleting…' : 'Delete'}
|
||||
{isDeleting ? t('settings.mcp.sidebar.actions.deleting') : t('settings.common.actions.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -6,12 +6,14 @@ import { useDeviceInfo } from '@/lib/device';
|
||||
import { toast } from '@/components/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const GITHUB_URL = 'https://github.com/btriapitsyn/openchamber';
|
||||
|
||||
const MIN_CHECKING_DURATION = 800; // ms
|
||||
|
||||
export const AboutSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
|
||||
const [showChecking, setShowChecking] = React.useState(false);
|
||||
const updateStore = useUpdateStore();
|
||||
@@ -32,13 +34,13 @@ export const AboutSettings: React.FC = () => {
|
||||
setShowChecking(false);
|
||||
// Show toast if check completed with no update available
|
||||
if (didInitiateCheck.current && !updateStore.available && !updateStore.error) {
|
||||
toast.success('You are on the latest version');
|
||||
toast.success(t('settings.openchamber.about.toast.latestVersion'));
|
||||
didInitiateCheck.current = false;
|
||||
}
|
||||
}, MIN_CHECKING_DURATION);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [updateStore.checking, showChecking, updateStore.available, updateStore.error]);
|
||||
}, [t, updateStore.checking, showChecking, updateStore.available, updateStore.error]);
|
||||
|
||||
const isChecking = updateStore.checking || showChecking;
|
||||
|
||||
@@ -61,7 +63,7 @@ export const AboutSettings: React.FC = () => {
|
||||
isChecking && 'animate-pulse [animation-duration:1s]'
|
||||
)}
|
||||
>
|
||||
Check updates
|
||||
{t('settings.openchamber.about.actions.checkUpdates')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -71,7 +73,7 @@ export const AboutSettings: React.FC = () => {
|
||||
className="flex items-center gap-1 typography-meta text-[var(--primary-base)] hover:underline"
|
||||
>
|
||||
<RiDownloadLine className="h-3.5 w-3.5" />
|
||||
Update
|
||||
{t('settings.openchamber.about.actions.update')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -135,14 +137,14 @@ export const AboutSettings: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-3 px-1">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||
About OpenChamber
|
||||
{t('settings.openchamber.about.title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden flex flex-col">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 px-4 py-3 border-b border-[var(--surface-subtle)]">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="typography-ui-label text-foreground">Version</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.about.field.version')}</span>
|
||||
<span className="typography-meta text-muted-foreground font-mono">{currentVersion}</span>
|
||||
</div>
|
||||
|
||||
@@ -150,7 +152,7 @@ export const AboutSettings: React.FC = () => {
|
||||
{updateStore.checking && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<RiLoaderLine className="h-4 w-4 animate-spin" />
|
||||
<span className="typography-meta">Checking...</span>
|
||||
<span className="typography-meta">{t('settings.openchamber.about.state.checking')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -160,12 +162,12 @@ export const AboutSettings: React.FC = () => {
|
||||
onClick={() => setUpdateDialogOpen(true)}
|
||||
>
|
||||
<RiDownloadLine className="h-4 w-4 mr-1" />
|
||||
Update to {updateStore.info?.version}
|
||||
{t('settings.openchamber.about.actions.updateToVersion', { version: updateStore.info?.version || '' })}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!updateStore.checking && !updateStore.available && !updateStore.error && (
|
||||
<span className="typography-meta text-muted-foreground">Up to date</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.openchamber.about.state.upToDate')}</span>
|
||||
)}
|
||||
|
||||
<Button size="sm"
|
||||
@@ -173,7 +175,7 @@ export const AboutSettings: React.FC = () => {
|
||||
onClick={() => updateStore.checkForUpdates()}
|
||||
disabled={updateStore.checking}
|
||||
>
|
||||
Check for updates
|
||||
{t('settings.openchamber.about.actions.checkForUpdates')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const getDisplayModel = (
|
||||
storedModel: string | undefined
|
||||
@@ -23,6 +24,7 @@ const getDisplayModel = (
|
||||
};
|
||||
|
||||
export const DefaultsSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const setProvider = useConfigStore((state) => state.setProvider);
|
||||
const setModel = useConfigStore((state) => state.setModel);
|
||||
const setAgent = useConfigStore((state) => state.setAgent);
|
||||
@@ -149,10 +151,10 @@ export const DefaultsSettings: React.FC = () => {
|
||||
|
||||
const formatVariantLabel = React.useCallback((variant: string) => {
|
||||
if (variant === DEFAULT_VARIANT_VALUE) {
|
||||
return 'Default';
|
||||
return t('settings.openchamber.defaults.option.default');
|
||||
}
|
||||
return variant.charAt(0).toUpperCase() + variant.slice(1);
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleVariantChange = React.useCallback(
|
||||
async (variant: string) => {
|
||||
@@ -227,20 +229,21 @@ export const DefaultsSettings: React.FC = () => {
|
||||
<div className="mb-6">
|
||||
<div className="mb-0.5 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Session Defaults</h3>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.defaults.title')}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-0">
|
||||
<div className="mt-0 mb-1 typography-meta text-muted-foreground">
|
||||
New sessions will start with:{' '}
|
||||
{t('settings.openchamber.defaults.summaryPrefix')}
|
||||
{' '}
|
||||
{parsedModel.providerId ? (
|
||||
<span className="text-foreground">
|
||||
{parsedModel.providerId}/{parsedModel.modelId}
|
||||
{supportsVariants ? ` (${defaultVariant ?? 'default'})` : ''}
|
||||
{supportsVariants ? ` (${defaultVariant ?? t('settings.openchamber.defaults.option.defaultLowercase')})` : ''}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-foreground">opencode agent default</span>
|
||||
<span className="text-foreground">{t('settings.openchamber.defaults.summaryOpenCodeDefault')}</span>
|
||||
)}
|
||||
{defaultAgent && (
|
||||
<>
|
||||
@@ -252,7 +255,7 @@ export const DefaultsSettings: React.FC = () => {
|
||||
|
||||
<div className={cn('flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8')}>
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">Default Model</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.defaults.field.defaultModel')}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
|
||||
<ModelSelector providerId={parsedModel.providerId} modelId={parsedModel.modelId} onChange={handleModelChange} />
|
||||
@@ -261,17 +264,17 @@ export const DefaultsSettings: React.FC = () => {
|
||||
|
||||
<div className="flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">Default Thinking</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.defaults.field.defaultThinking')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:w-fit">
|
||||
<Select value={defaultVariant ?? DEFAULT_VARIANT_VALUE} onValueChange={handleVariantChange} disabled={!supportsVariants}>
|
||||
<SelectTrigger className="w-fit min-w-[120px]">
|
||||
<SelectValue placeholder="Thinking">
|
||||
<SelectValue placeholder={t('settings.openchamber.defaults.field.thinkingPlaceholder')}>
|
||||
{formatVariantLabel(defaultVariant ?? DEFAULT_VARIANT_VALUE)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={DEFAULT_VARIANT_VALUE}>Default</SelectItem>
|
||||
<SelectItem value={DEFAULT_VARIANT_VALUE}>{t('settings.openchamber.defaults.option.default')}</SelectItem>
|
||||
{availableVariants.map((variant) => (
|
||||
<SelectItem key={variant} value={variant}>
|
||||
{formatVariantLabel(variant)}
|
||||
@@ -284,7 +287,7 @@ export const DefaultsSettings: React.FC = () => {
|
||||
|
||||
<div className="flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">Default Agent</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.defaults.field.defaultAgent')}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
|
||||
<AgentSelector agentName={defaultAgent || ''} onChange={handleAgentChange} />
|
||||
@@ -304,8 +307,8 @@ export const DefaultsSettings: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={showDeletionDialog} onChange={setShowDeletionDialog} ariaLabel="Show deletion dialog" />
|
||||
<span className="typography-ui-label text-foreground">Show Deletion Dialog</span>
|
||||
<Checkbox checked={showDeletionDialog} onChange={setShowDeletionDialog} ariaLabel={t('settings.openchamber.defaults.field.showDeletionDialogAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.defaults.field.showDeletionDialog')}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -321,8 +324,8 @@ export const DefaultsSettings: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={settingsDefaultFileViewerPreview} onChange={setSettingsDefaultFileViewerPreview} ariaLabel="Open files in preview mode" />
|
||||
<span className="typography-ui-label text-foreground">Open files in preview mode</span>
|
||||
<Checkbox checked={settingsDefaultFileViewerPreview} onChange={setSettingsDefaultFileViewerPreview} ariaLabel={t('settings.openchamber.defaults.field.openFilesPreviewAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.defaults.field.openFilesPreview')}</span>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
@@ -3,8 +3,10 @@ import * as React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { getDesktopLanAddress, isDesktopLocalOriginActive, isDesktopShell, restartDesktopApp } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export const DesktopNetworkSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const isLocalDesktop = isDesktopShell() && isDesktopLocalOriginActive();
|
||||
const [savedValue, setSavedValue] = React.useState(false);
|
||||
const [draftValue, setDraftValue] = React.useState(false);
|
||||
@@ -27,7 +29,7 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load desktop settings');
|
||||
throw new Error(t('settings.openchamber.desktopNetwork.error.loadFailed'));
|
||||
}
|
||||
|
||||
const data = (await response.json().catch(() => null)) as null | { desktopLanAccessEnabled?: unknown };
|
||||
@@ -41,7 +43,7 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
setError(null);
|
||||
} catch (cause) {
|
||||
if (!cancelled) {
|
||||
setError(cause instanceof Error ? cause.message : 'Failed to load desktop settings');
|
||||
setError(cause instanceof Error ? cause.message : t('settings.openchamber.desktopNetwork.error.loadFailed'));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
@@ -53,7 +55,7 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isLocalDesktop]);
|
||||
}, [isLocalDesktop, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isLocalDesktop || !draftValue) {
|
||||
@@ -109,20 +111,20 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save desktop settings');
|
||||
throw new Error(t('settings.openchamber.desktopNetwork.error.saveFailed'));
|
||||
}
|
||||
|
||||
setSavedValue(draftValue);
|
||||
|
||||
const restarted = await restartDesktopApp();
|
||||
if (!restarted) {
|
||||
throw new Error('Saved, but failed to restart app');
|
||||
throw new Error(t('settings.openchamber.desktopNetwork.error.savedRestartFailed'));
|
||||
}
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : 'Failed to save desktop settings');
|
||||
setError(cause instanceof Error ? cause.message : t('settings.openchamber.desktopNetwork.error.saveFailed'));
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [draftValue, isDirty]);
|
||||
}, [draftValue, isDirty, t]);
|
||||
|
||||
if (!isLocalDesktop) {
|
||||
return null;
|
||||
@@ -131,7 +133,7 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Desktop Network Access</h3>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.desktopNetwork.title')}</h3>
|
||||
</div>
|
||||
|
||||
<section className="space-y-2 px-2 pb-2 pt-0">
|
||||
@@ -150,16 +152,16 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
<Checkbox
|
||||
checked={draftValue}
|
||||
onChange={handleToggle}
|
||||
ariaLabel="Allow LAN access to desktop sidecar"
|
||||
ariaLabel={t('settings.openchamber.desktopNetwork.field.allowLanAccessAria')}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="typography-ui-label text-foreground">Let other devices on your local network open this app</div>
|
||||
<div className="typography-ui-label text-foreground">{t('settings.openchamber.desktopNetwork.field.allowLanAccess')}</div>
|
||||
<div className="typography-micro text-muted-foreground/70">
|
||||
Restarts the app so phones, tablets, and other computers on your Wi-Fi can open it.
|
||||
{t('settings.openchamber.desktopNetwork.field.allowLanAccessDescription')}
|
||||
</div>
|
||||
<div className="typography-micro text-[var(--status-warning)]/85">
|
||||
Warning: while enabled, the app is reachable by anyone on the same local network.
|
||||
{t('settings.openchamber.desktopNetwork.field.warning')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,7 +172,9 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
|
||||
{lanUrl ? (
|
||||
<div className="px-2 typography-micro text-muted-foreground/80">
|
||||
{isDirty && !savedValue ? 'After restart, open from another device: ' : 'Open from another device: '}
|
||||
{isDirty && !savedValue
|
||||
? t('settings.openchamber.desktopNetwork.hint.openAfterRestart')
|
||||
: t('settings.openchamber.desktopNetwork.hint.openNow')}
|
||||
<span className="font-mono text-foreground">{lanUrl}</span>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -183,7 +187,7 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
disabled={isLoading || isSaving || !isDirty}
|
||||
className="shrink-0 !font-normal"
|
||||
>
|
||||
{isSaving ? 'Saving…' : 'Save + Restart'}
|
||||
{isSaving ? t('settings.common.actions.saving') : t('settings.openchamber.desktopNetwork.actions.saveAndRestart')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { GitHubAuthStatus } from '@/lib/api/types';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { RiGithubFill, RiInformationLine } from '@remixicon/react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
@@ -33,6 +34,7 @@ type DeviceFlowCompleteResponse =
|
||||
| { connected: false; status?: string; error?: string };
|
||||
|
||||
export const GitHubSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const runtimeGitHub = getRegisteredRuntimeAPIs()?.github;
|
||||
const status = useGitHubAuthStore((state) => state.status);
|
||||
@@ -101,11 +103,11 @@ export const GitHubSettings: React.FC = () => {
|
||||
void openExternal(url);
|
||||
} catch (error) {
|
||||
console.error('Failed to start GitHub connect:', error);
|
||||
toast.error('Failed to start GitHub connect');
|
||||
toast.error(t('settings.github.page.toast.startConnectFailed'));
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}, [openExternal, runtimeGitHub]);
|
||||
}, [openExternal, runtimeGitHub, t]);
|
||||
|
||||
const pollOnce = React.useCallback(async (deviceCode: string) => {
|
||||
if (runtimeGitHub) {
|
||||
@@ -141,7 +143,7 @@ export const GitHubSettings: React.FC = () => {
|
||||
try {
|
||||
const result = await pollOnce(flow.deviceCode);
|
||||
if (result.connected) {
|
||||
toast.success('GitHub connected');
|
||||
toast.success(t('settings.github.page.toast.connected'));
|
||||
setFlow(null);
|
||||
stopPolling();
|
||||
await refreshStatus(runtimeGitHub, { force: true });
|
||||
@@ -153,7 +155,7 @@ export const GitHubSettings: React.FC = () => {
|
||||
}
|
||||
|
||||
if (result.status === 'expired_token' || result.status === 'access_denied') {
|
||||
toast.error(result.error || 'GitHub authorization failed');
|
||||
toast.error(result.error || t('settings.github.page.toast.authorizationFailed'));
|
||||
setFlow(null);
|
||||
stopPolling();
|
||||
}
|
||||
@@ -169,7 +171,7 @@ export const GitHubSettings: React.FC = () => {
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [flow, pollIntervalMs, pollOnce, refreshStatus, runtimeGitHub, stopPolling]);
|
||||
}, [flow, pollIntervalMs, pollOnce, refreshStatus, runtimeGitHub, stopPolling, t]);
|
||||
|
||||
const disconnect = React.useCallback(async () => {
|
||||
setIsBusy(true);
|
||||
@@ -187,15 +189,15 @@ export const GitHubSettings: React.FC = () => {
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
}
|
||||
toast.success('GitHub disconnected');
|
||||
toast.success(t('settings.github.page.toast.disconnected'));
|
||||
await refreshStatus(runtimeGitHub, { force: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect GitHub:', error);
|
||||
toast.error('Failed to disconnect GitHub');
|
||||
toast.error(t('settings.github.page.toast.disconnectFailed'));
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}, [refreshStatus, runtimeGitHub, stopPolling]);
|
||||
}, [refreshStatus, runtimeGitHub, stopPolling, t]);
|
||||
|
||||
const activateAccount = React.useCallback(async (accountId: string) => {
|
||||
if (!accountId) return;
|
||||
@@ -220,14 +222,14 @@ export const GitHubSettings: React.FC = () => {
|
||||
})();
|
||||
|
||||
setStatus(payload);
|
||||
toast.success('GitHub account switched');
|
||||
toast.success(t('settings.github.page.toast.accountSwitched'));
|
||||
} catch (error) {
|
||||
console.error('Failed to switch GitHub account:', error);
|
||||
toast.error('Failed to switch GitHub account');
|
||||
toast.error(t('settings.github.page.toast.accountSwitchFailed'));
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}, [runtimeGitHub, setStatus]);
|
||||
}, [runtimeGitHub, setStatus, t]);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
@@ -247,7 +249,7 @@ export const GitHubSettings: React.FC = () => {
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Connect a GitHub account for in-app PR and issue workflows.
|
||||
{t('settings.github.page.tooltip.connectAccount')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -260,7 +262,7 @@ export const GitHubSettings: React.FC = () => {
|
||||
{user?.avatarUrl ? (
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={user.login ? `${user.login} avatar` : 'GitHub avatar'}
|
||||
alt={user.login ? t('settings.github.page.avatarAlt.withLogin', { login: user.login }) : t('settings.github.page.avatarAlt.fallback')}
|
||||
className="h-10 w-10 shrink-0 rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)] object-cover"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
@@ -275,34 +277,38 @@ export const GitHubSettings: React.FC = () => {
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2 typography-meta text-muted-foreground mt-0.5", isMobile ? "flex-wrap" : "truncate")}>
|
||||
<RiGithubFill className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="font-mono">{user?.login || 'unknown'}</span>
|
||||
<span className="font-mono">{user?.login || t('settings.github.page.label.unknownUser')}</span>
|
||||
{user?.email && <span className="opacity-50">•</span>}
|
||||
{user?.email && <span>{user.email}</span>}
|
||||
</div>
|
||||
{status?.scope && (
|
||||
<div className="typography-micro text-muted-foreground/70 mt-0.5">Scopes: {status.scope}</div>
|
||||
<div className="typography-micro text-muted-foreground/70 mt-0.5">
|
||||
{t('settings.github.page.label.scopes', { value: status.scope })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button size="sm" variant="outline" onClick={disconnect} disabled={isBusy} className={cn("text-[var(--status-error)] hover:text-[var(--status-error)]", isMobile ? "w-full" : undefined)}>
|
||||
Disconnect
|
||||
{t('settings.github.page.actions.disconnect')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-4">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="typography-ui-label text-foreground">Not Connected</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.github.page.status.notConnected')}</span>
|
||||
</div>
|
||||
<Button size="sm" variant="default" onClick={startConnect} disabled={isBusy}>
|
||||
Connect GitHub
|
||||
{t('settings.github.page.actions.connect')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{accounts.length > 1 && (
|
||||
<div className="mt-2 border-t border-[var(--surface-subtle)] pt-2 px-2 pb-1">
|
||||
<div className="typography-micro text-muted-foreground mb-2 px-1">Other Accounts</div>
|
||||
<div className="typography-micro text-muted-foreground mb-2 px-1">
|
||||
{t('settings.github.page.label.otherAccounts')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{accounts.map((account) => {
|
||||
const accountUser = account.user;
|
||||
@@ -316,7 +322,7 @@ export const GitHubSettings: React.FC = () => {
|
||||
{accountUser?.avatarUrl ? (
|
||||
<img
|
||||
src={accountUser.avatarUrl}
|
||||
alt={accountUser.login ? `${accountUser.login} avatar` : 'GitHub avatar'}
|
||||
alt={accountUser.login ? t('settings.github.page.avatarAlt.withLogin', { login: accountUser.login }) : t('settings.github.page.avatarAlt.fallback')}
|
||||
className="h-6 w-6 shrink-0 rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)] object-cover"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
@@ -338,14 +344,16 @@ export const GitHubSettings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
{isCurrent ? (
|
||||
<span className="typography-micro text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-1.5 py-0.5 rounded">Active</span>
|
||||
<span className="typography-micro text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-1.5 py-0.5 rounded">
|
||||
{t('settings.github.page.status.active')}
|
||||
</span>
|
||||
) : (
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => activateAccount(account.id)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
Switch to
|
||||
{t('settings.github.page.actions.switchTo')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -365,7 +373,7 @@ export const GitHubSettings: React.FC = () => {
|
||||
disabled={isBusy}
|
||||
className={cn(isMobile ? 'w-full' : undefined)}
|
||||
>
|
||||
Add Account
|
||||
{t('settings.github.page.actions.addAccount')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -373,9 +381,9 @@ export const GitHubSettings: React.FC = () => {
|
||||
{flow && (
|
||||
<div className="mt-4 rounded-lg bg-[var(--surface-elevated)]/70 p-4 border border-[var(--interactive-border)]">
|
||||
<div className="space-y-1">
|
||||
<h4 className="typography-ui-label text-foreground">Authorize OpenChamber</h4>
|
||||
<h4 className="typography-ui-label text-foreground">{t('settings.github.page.flow.title')}</h4>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
In GitHub, enter the following code to authorize this device:
|
||||
{t('settings.github.page.flow.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 mt-4">
|
||||
@@ -386,19 +394,19 @@ export const GitHubSettings: React.FC = () => {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Open GitHub
|
||||
{t('settings.github.page.actions.openGithub')}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<span className="typography-micro text-muted-foreground animate-pulse">
|
||||
Waiting for approval… (auto-refresh)
|
||||
{t('settings.github.page.flow.waiting')}
|
||||
</span>
|
||||
<Button size="sm" variant="ghost" disabled={isBusy} onClick={() => {
|
||||
stopPolling();
|
||||
setFlow(null);
|
||||
}}>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,10 @@ import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { setFilesViewShowGitignored, useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export const GitSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const settingsGitmojiEnabled = useConfigStore((state) => state.settingsGitmojiEnabled);
|
||||
const setSettingsGitmojiEnabled = useConfigStore((state) => state.setSettingsGitmojiEnabled);
|
||||
const showGitignored = useFilesViewShowGitignored();
|
||||
@@ -15,6 +17,13 @@ export const GitSettings: React.FC = () => {
|
||||
const setGitChangesViewMode = useUIStore((state) => state.setGitChangesViewMode);
|
||||
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const viewOptions = React.useMemo(
|
||||
() => [
|
||||
{ id: 'flat' as const, label: t('settings.openchamber.git.option.flatList') },
|
||||
{ id: 'tree' as const, label: t('settings.openchamber.git.option.treeView') },
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
type GitSettingsPayload = {
|
||||
gitmojiEnabled?: boolean;
|
||||
@@ -108,17 +117,14 @@ export const GitSettings: React.FC = () => {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Git Preferences</h3>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.git.title')}</h3>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-0.5">
|
||||
<div className="pt-1 pb-1">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">Changes View</h4>
|
||||
<div role="radiogroup" aria-label="Git changes view mode" className="mt-0.5 space-y-0">
|
||||
{[
|
||||
{ id: 'flat' as const, label: 'Flat List' },
|
||||
{ id: 'tree' as const, label: 'Tree View' },
|
||||
].map((option) => {
|
||||
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.git.changesViewTitle')}</h4>
|
||||
<div role="radiogroup" aria-label={t('settings.openchamber.git.changesViewAria')} className="mt-0.5 space-y-0">
|
||||
{viewOptions.map((option) => {
|
||||
const selected = gitChangesViewMode === option.id;
|
||||
return (
|
||||
<div
|
||||
@@ -138,7 +144,7 @@ export const GitSettings: React.FC = () => {
|
||||
<Radio
|
||||
checked={selected}
|
||||
onChange={() => { handleGitChangesViewModeChange(option.id); }}
|
||||
ariaLabel={`Git changes view mode: ${option.label}`}
|
||||
ariaLabel={t('settings.openchamber.git.optionAria', { option: option.label })}
|
||||
/>
|
||||
<span className={selected ? 'typography-ui-label font-normal text-foreground' : 'typography-ui-label font-normal text-foreground/50'}>
|
||||
{option.label}
|
||||
@@ -169,9 +175,9 @@ export const GitSettings: React.FC = () => {
|
||||
onChange={(checked) => {
|
||||
void handleGitmojiChange(checked);
|
||||
}}
|
||||
ariaLabel="Enable Gitmoji picker"
|
||||
ariaLabel={t('settings.openchamber.git.enableGitmojiAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Enable Gitmoji Picker</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.git.enableGitmoji')}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -190,9 +196,9 @@ export const GitSettings: React.FC = () => {
|
||||
<Checkbox
|
||||
checked={showGitignored}
|
||||
onChange={setFilesViewShowGitignored}
|
||||
ariaLabel="Display gitignored files"
|
||||
ariaLabel={t('settings.openchamber.git.showGitignoredAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Display Gitignored Files</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.git.showGitignored')}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
UNASSIGNED_SHORTCUT,
|
||||
type ShortcutCombo,
|
||||
} from '@/lib/shortcuts';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const MODIFIER_KEYS = new Set(['shift', 'control', 'alt', 'meta']);
|
||||
|
||||
@@ -45,12 +46,19 @@ const keyboardEventToCombo = (event: React.KeyboardEvent<HTMLInputElement>): Sho
|
||||
};
|
||||
|
||||
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 actions = React.useMemo(() => getCustomizableShortcutActions(), []);
|
||||
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>>({});
|
||||
@@ -88,13 +96,13 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
setShortcutOverride(actionId, normalized);
|
||||
setPendingOverwrite(null);
|
||||
setErrorText('');
|
||||
setWarningText(isRiskyBrowserShortcut(normalized) ? 'This shortcut can conflict with browser defaults. It is still saved.' : '');
|
||||
setWarningText(isRiskyBrowserShortcut(normalized) ? t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut') : '');
|
||||
setDraftByAction((current) => {
|
||||
const rest = { ...current };
|
||||
delete rest[actionId];
|
||||
return rest;
|
||||
});
|
||||
}, [findConflict, setShortcutOverride]);
|
||||
}, [findConflict, setShortcutOverride, t]);
|
||||
|
||||
const confirmOverwrite = React.useCallback(() => {
|
||||
if (!pendingOverwrite) {
|
||||
@@ -105,13 +113,13 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
setShortcutOverride(pendingOverwrite.actionId, pendingOverwrite.combo);
|
||||
setPendingOverwrite(null);
|
||||
setErrorText('');
|
||||
setWarningText(isRiskyBrowserShortcut(pendingOverwrite.combo) ? 'This shortcut can conflict with browser defaults. It is still saved.' : '');
|
||||
setWarningText(isRiskyBrowserShortcut(pendingOverwrite.combo) ? t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut') : '');
|
||||
setDraftByAction((current) => {
|
||||
const rest = { ...current };
|
||||
delete rest[pendingOverwrite.actionId];
|
||||
return rest;
|
||||
});
|
||||
}, [pendingOverwrite, setShortcutOverride]);
|
||||
}, [pendingOverwrite, setShortcutOverride, t]);
|
||||
|
||||
const resetOne = React.useCallback((actionId: string) => {
|
||||
clearShortcutOverride(actionId);
|
||||
@@ -129,7 +137,7 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Keyboard Shortcuts</h3>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.keyboardShortcuts.title')}</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -143,14 +151,14 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
setWarningText('');
|
||||
}}
|
||||
>
|
||||
Reset All
|
||||
{t('settings.openchamber.keyboardShortcuts.actions.resetAll')}
|
||||
</Button>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Capture a new key combo, save it, and bindings will update immediately.
|
||||
{t('settings.openchamber.keyboardShortcuts.tooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -161,11 +169,11 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
{pendingOverwrite && (
|
||||
<div className="rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<span className="typography-meta text-foreground">
|
||||
This combo is already used by another shortcut. Overwrite and clear that other mapping?
|
||||
{t('settings.openchamber.keyboardShortcuts.overwritePrompt')}
|
||||
</span>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button type="button" size="xs" className="!font-normal" onClick={confirmOverwrite}>Overwrite</Button>
|
||||
<Button type="button" size="xs" className="!font-normal" variant="ghost" onClick={() => setPendingOverwrite(null)}>Cancel</Button>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -192,12 +200,12 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
return (
|
||||
<div key={action.id} className={cn("flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8", index > 0 && "border-t border-[var(--surface-subtle)]")}>
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">{action.label}</span>
|
||||
<span className="typography-ui-label text-foreground">{actionLabel(action.id, action.label)}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
|
||||
<Input
|
||||
readOnly
|
||||
value={capturingActionId === action.id ? 'Press keys...' : formatShortcutForDisplay(displayCombo)}
|
||||
value={capturingActionId === action.id ? t('settings.openchamber.keyboardShortcuts.field.pressKeys') : formatShortcutForDisplay(displayCombo)}
|
||||
onFocus={() => {
|
||||
setCapturingActionId(action.id);
|
||||
setErrorText('');
|
||||
@@ -239,17 +247,17 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
onClick={() => {
|
||||
const next = draftByAction[action.id];
|
||||
if (!next) {
|
||||
setErrorText('Capture a shortcut first.');
|
||||
setErrorText(t('settings.openchamber.keyboardShortcuts.error.captureFirst'));
|
||||
return;
|
||||
}
|
||||
saveCombo(action.id, next);
|
||||
}}
|
||||
disabled={!hasDraft}
|
||||
>
|
||||
Save
|
||||
{t('settings.common.actions.saveChanges')}
|
||||
</Button>
|
||||
<Button type="button" size="xs" className="!font-normal" variant="ghost" onClick={() => resetOne(action.id)}>
|
||||
Reset
|
||||
{t('settings.common.actions.reset')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,13 +14,33 @@ import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const DEFAULT_NOTIFICATION_TEMPLATES = {
|
||||
completion: { title: '{agent_name} is ready', message: '{model_name} completed the task' },
|
||||
error: { title: 'Tool error', message: '{last_message}' },
|
||||
question: { title: 'Input needed', message: '{last_message}' },
|
||||
subtask: { title: '{agent_name} is ready', message: '{model_name} completed the task' },
|
||||
completion: {
|
||||
titleKey: 'settings.notifications.page.template.defaults.completion.title',
|
||||
messageKey: 'settings.notifications.page.template.defaults.completion.message',
|
||||
},
|
||||
error: {
|
||||
titleKey: 'settings.notifications.page.template.defaults.error.title',
|
||||
messageKey: 'settings.notifications.page.template.defaults.error.message',
|
||||
},
|
||||
question: {
|
||||
titleKey: 'settings.notifications.page.template.defaults.question.title',
|
||||
messageKey: 'settings.notifications.page.template.defaults.question.message',
|
||||
},
|
||||
subtask: {
|
||||
titleKey: 'settings.notifications.page.template.defaults.subtask.title',
|
||||
messageKey: 'settings.notifications.page.template.defaults.subtask.message',
|
||||
},
|
||||
} as const;
|
||||
type NotificationTemplateEvent = keyof typeof DEFAULT_NOTIFICATION_TEMPLATES;
|
||||
const TEMPLATE_EVENT_LABEL_KEYS = {
|
||||
completion: 'settings.notifications.page.template.event.completion',
|
||||
subtask: 'settings.notifications.page.template.event.subtask',
|
||||
error: 'settings.notifications.page.template.event.error',
|
||||
question: 'settings.notifications.page.template.event.question',
|
||||
} as const satisfies Record<NotificationTemplateEvent, string>;
|
||||
|
||||
const UTILITY_PROVIDER_ID = 'zen';
|
||||
const UTILITY_PREFERRED_MODEL_ID = 'big-pickle';
|
||||
@@ -31,6 +51,7 @@ const DEFAULT_SUMMARY_LENGTH = 100;
|
||||
const DEFAULT_MAX_LAST_MESSAGE_LENGTH = 250;
|
||||
|
||||
export const NotificationSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const isDesktop = React.useMemo(() => isDesktopShell(), []);
|
||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
@@ -213,13 +234,13 @@ export const NotificationSettings: React.FC = () => {
|
||||
if (permission === 'granted') {
|
||||
setNativeNotificationsEnabled(true);
|
||||
} else {
|
||||
toast.error('Notification permission denied', {
|
||||
description: 'Please enable notifications in your browser settings.',
|
||||
toast.error(t('settings.notifications.page.toast.permissionDenied.title'), {
|
||||
description: t('settings.notifications.page.toast.permissionDenied.description'),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to request notification permission:', error);
|
||||
toast.error('Failed to request notification permission');
|
||||
toast.error(t('settings.notifications.page.toast.requestPermissionFailed'));
|
||||
}
|
||||
} else if (checked && notificationPermission === 'granted') {
|
||||
setNativeNotificationsEnabled(true);
|
||||
@@ -388,37 +409,37 @@ export const NotificationSettings: React.FC = () => {
|
||||
const handleTestNotification = async () => {
|
||||
const apis = getRegisteredRuntimeAPIs();
|
||||
if (!apis?.notifications) {
|
||||
toast.error('Notifications API not available');
|
||||
toast.error(t('settings.notifications.page.toast.notificationsApiUnavailable'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await apis.notifications.notifyAgentCompletion({
|
||||
title: 'Test Notification',
|
||||
body: 'This is a test notification from OpenChamber.',
|
||||
title: t('settings.notifications.page.testNotification.title'),
|
||||
body: t('settings.notifications.page.testNotification.body'),
|
||||
tag: 'openchamber-test',
|
||||
});
|
||||
|
||||
if (success) {
|
||||
toast.success('Test notification sent successfully');
|
||||
toast.success(t('settings.notifications.page.toast.testNotificationSent'));
|
||||
} else {
|
||||
toast.error('Failed to send test notification');
|
||||
toast.error(t('settings.notifications.page.toast.testNotificationFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Test notification failed:', error);
|
||||
toast.error('Failed to send test notification');
|
||||
toast.error(t('settings.notifications.page.toast.testNotificationFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnableBackgroundNotifications = async () => {
|
||||
if (!pushSupported) {
|
||||
toast.error('Push notifications not supported');
|
||||
toast.error(t('settings.notifications.page.toast.pushUnsupported'));
|
||||
return;
|
||||
}
|
||||
|
||||
const apis = getRegisteredRuntimeAPIs();
|
||||
if (!apis?.push) {
|
||||
toast.error('Push API not available');
|
||||
toast.error(t('settings.notifications.page.toast.pushApiUnavailable'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -428,23 +449,23 @@ export const NotificationSettings: React.FC = () => {
|
||||
const permission = await Notification.requestPermission();
|
||||
setNotificationPermission(permission);
|
||||
if (permission !== 'granted') {
|
||||
toast.error('Notification permission denied', {
|
||||
description: 'Enable notifications in your browser settings.',
|
||||
toast.error(t('settings.notifications.page.toast.permissionDenied.title'), {
|
||||
description: t('settings.notifications.page.toast.permissionDenied.enableInBrowser'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof Notification !== 'undefined' && Notification.permission !== 'granted') {
|
||||
toast.error('Notification permission denied', {
|
||||
description: 'Enable notifications in your browser settings.',
|
||||
toast.error(t('settings.notifications.page.toast.permissionDenied.title'), {
|
||||
description: t('settings.notifications.page.toast.permissionDenied.enableInBrowser'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const key = await apis.push.getVapidPublicKey();
|
||||
if (!key?.publicKey) {
|
||||
toast.error('Failed to load push key');
|
||||
toast.error(t('settings.notifications.page.toast.pushKeyLoadFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -486,16 +507,16 @@ export const NotificationSettings: React.FC = () => {
|
||||
);
|
||||
|
||||
if (!ok?.ok) {
|
||||
toast.error('Failed to enable background notifications');
|
||||
toast.error(t('settings.notifications.page.toast.enableBackgroundFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
setPushSubscribed(true);
|
||||
toast.success('Background notifications enabled');
|
||||
toast.success(t('settings.notifications.page.toast.backgroundEnabled'));
|
||||
} catch (error) {
|
||||
console.error('[Push] Enable failed:', error);
|
||||
const formatted = formatUnknownError(error);
|
||||
toast.error('Failed to enable background notifications', {
|
||||
toast.error(t('settings.notifications.page.toast.enableBackgroundFailed'), {
|
||||
description: formatted.summary,
|
||||
});
|
||||
} finally {
|
||||
@@ -511,7 +532,7 @@ export const NotificationSettings: React.FC = () => {
|
||||
|
||||
const apis = getRegisteredRuntimeAPIs();
|
||||
if (!apis?.push) {
|
||||
toast.error('Push API not available');
|
||||
toast.error(t('settings.notifications.page.toast.pushApiUnavailable'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -528,7 +549,7 @@ export const NotificationSettings: React.FC = () => {
|
||||
await subscription.unsubscribe();
|
||||
await apis.push.unsubscribe({ endpoint });
|
||||
setPushSubscribed(false);
|
||||
toast.success('Background notifications disabled');
|
||||
toast.success(t('settings.notifications.page.toast.backgroundDisabled'));
|
||||
} finally {
|
||||
setPushBusy(false);
|
||||
}
|
||||
@@ -541,7 +562,7 @@ export const NotificationSettings: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Notification Delivery
|
||||
{t('settings.notifications.page.delivery.title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -566,9 +587,9 @@ export const NotificationSettings: React.FC = () => {
|
||||
onChange={(checked) => {
|
||||
void handleToggleChange(checked);
|
||||
}}
|
||||
ariaLabel="Enable notifications"
|
||||
ariaLabel={t('settings.notifications.page.delivery.enableAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Enable Notifications</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.delivery.enableLabel')}</span>
|
||||
</div>
|
||||
|
||||
{nativeNotificationsEnabled && canShowNotifications && (
|
||||
@@ -589,9 +610,9 @@ export const NotificationSettings: React.FC = () => {
|
||||
<Checkbox
|
||||
checked={notificationMode === 'always'}
|
||||
onChange={(checked) => setNotificationMode(checked ? 'always' : 'hidden-only')}
|
||||
ariaLabel="Notify while app is focused"
|
||||
ariaLabel={t('settings.notifications.page.delivery.focusedAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Notify While App is Focused</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.delivery.focusedLabel')}</span>
|
||||
</div>
|
||||
|
||||
<div className="py-2">
|
||||
@@ -601,7 +622,7 @@ export const NotificationSettings: React.FC = () => {
|
||||
size="sm"
|
||||
onClick={() => void handleTestNotification()}
|
||||
>
|
||||
Send test notification
|
||||
{t('settings.notifications.page.delivery.testAction')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -611,16 +632,16 @@ export const NotificationSettings: React.FC = () => {
|
||||
{isBrowser && (
|
||||
<div className="mt-1 px-2">
|
||||
<p className="typography-meta text-muted-foreground/70">
|
||||
Your browser may ask for permission the first time.
|
||||
{t('settings.notifications.page.delivery.browserPermissionHint')}
|
||||
</p>
|
||||
{notificationPermission === 'denied' && (
|
||||
<p className="typography-meta text-[var(--status-error)] mt-1">
|
||||
Notification permission denied. Enable it in your browser settings.
|
||||
{t('settings.notifications.page.delivery.permissionDenied')}
|
||||
</p>
|
||||
)}
|
||||
{notificationPermission === 'granted' && !nativeNotificationsEnabled && (
|
||||
<p className="typography-meta text-muted-foreground/70 mt-1">
|
||||
Permission granted, but notifications are disabled.
|
||||
{t('settings.notifications.page.delivery.permissionGrantedButDisabled')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -628,7 +649,7 @@ export const NotificationSettings: React.FC = () => {
|
||||
{isVSCode && (
|
||||
<div className="mt-1 px-2">
|
||||
<p className="typography-meta text-muted-foreground/70">
|
||||
When enabled, notifications are delivered through VS Code native notifications.
|
||||
{t('settings.notifications.page.delivery.vscodeHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -640,7 +661,7 @@ export const NotificationSettings: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Notification Events
|
||||
{t('settings.notifications.page.events.title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -658,8 +679,8 @@ export const NotificationSettings: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={notifyOnCompletion} onChange={setNotifyOnCompletion} ariaLabel="Agent completion" />
|
||||
<span className="typography-ui-label text-foreground">Agent Completion</span>
|
||||
<Checkbox checked={notifyOnCompletion} onChange={setNotifyOnCompletion} ariaLabel={t('settings.notifications.page.events.completionAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.events.completionLabel')}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -675,8 +696,8 @@ export const NotificationSettings: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={notifyOnSubtasks} onChange={setNotifyOnSubtasks} ariaLabel="Subagent completion" />
|
||||
<span className="typography-ui-label text-foreground">Subagent Completion</span>
|
||||
<Checkbox checked={notifyOnSubtasks} onChange={setNotifyOnSubtasks} ariaLabel={t('settings.notifications.page.events.subtaskAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.events.subtaskLabel')}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -692,8 +713,8 @@ export const NotificationSettings: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={notifyOnError} onChange={setNotifyOnError} ariaLabel="Agent errors" />
|
||||
<span className="typography-ui-label text-foreground">Agent Errors</span>
|
||||
<Checkbox checked={notifyOnError} onChange={setNotifyOnError} ariaLabel={t('settings.notifications.page.events.errorAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.events.errorLabel')}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -709,8 +730,8 @@ export const NotificationSettings: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={notifyOnQuestion} onChange={setNotifyOnQuestion} ariaLabel="Agent questions" />
|
||||
<span className="typography-ui-label text-foreground">Agent Questions</span>
|
||||
<Checkbox checked={notifyOnQuestion} onChange={setNotifyOnQuestion} ariaLabel={t('settings.notifications.page.events.questionAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.events.questionLabel')}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -719,36 +740,43 @@ export const NotificationSettings: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Notification Templates
|
||||
{t('settings.notifications.page.template.title')}
|
||||
</h3>
|
||||
<p className="typography-meta text-muted-foreground mt-0.5">
|
||||
Variables: <code className="text-[var(--primary-base)]">{'{project_name}'}</code> <code className="text-[var(--primary-base)]">{'{worktree}'}</code> <code className="text-[var(--primary-base)]">{'{branch}'}</code> <code className="text-[var(--primary-base)]">{'{session_name}'}</code> <code className="text-[var(--primary-base)]">{'{agent_name}'}</code> <code className="text-[var(--primary-base)]">{'{model_name}'}</code> <code className="text-[var(--primary-base)]">{'{last_message}'}</code>
|
||||
{t('settings.notifications.page.template.variablesLabel')}{' '}
|
||||
<code className="text-[var(--primary-base)]">{'{project_name}'}</code>{' '}
|
||||
<code className="text-[var(--primary-base)]">{'{worktree}'}</code>{' '}
|
||||
<code className="text-[var(--primary-base)]">{'{branch}'}</code>{' '}
|
||||
<code className="text-[var(--primary-base)]">{'{session_name}'}</code>{' '}
|
||||
<code className="text-[var(--primary-base)]">{'{agent_name}'}</code>{' '}
|
||||
<code className="text-[var(--primary-base)]">{'{model_name}'}</code>{' '}
|
||||
<code className="text-[var(--primary-base)]">{'{last_message}'}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 md:gap-3">
|
||||
{(['completion', 'subtask', 'error', 'question'] as const).map((event) => (
|
||||
{(['completion', 'subtask', 'error', 'question'] as const).map((event: NotificationTemplateEvent) => (
|
||||
<section key={event} className="p-2">
|
||||
<span className="typography-ui-label text-foreground font-normal capitalize block">
|
||||
{event === 'subtask' ? 'Subagent Completion' : event}
|
||||
{t(TEMPLATE_EVENT_LABEL_KEYS[event])}
|
||||
</span>
|
||||
<div className="mt-1.5 space-y-2">
|
||||
<div>
|
||||
<label className="typography-micro text-muted-foreground block mb-1">Title</label>
|
||||
<label className="typography-micro text-muted-foreground block mb-1">{t('settings.notifications.page.template.field.title')}</label>
|
||||
<Input
|
||||
value={notificationTemplates[event].title}
|
||||
onChange={(e) => updateTemplate(event, 'title', e.target.value)}
|
||||
className="h-7"
|
||||
placeholder={DEFAULT_NOTIFICATION_TEMPLATES[event].title}
|
||||
placeholder={t(DEFAULT_NOTIFICATION_TEMPLATES[event].titleKey)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="typography-micro text-muted-foreground block mb-1">Message</label>
|
||||
<label className="typography-micro text-muted-foreground block mb-1">{t('settings.notifications.page.template.field.message')}</label>
|
||||
<Input
|
||||
value={notificationTemplates[event].message}
|
||||
onChange={(e) => updateTemplate(event, 'message', e.target.value)}
|
||||
className="h-7"
|
||||
placeholder={DEFAULT_NOTIFICATION_TEMPLATES[event].message}
|
||||
placeholder={t(DEFAULT_NOTIFICATION_TEMPLATES[event].messageKey)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -761,7 +789,7 @@ export const NotificationSettings: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
AI Summarization
|
||||
{t('settings.notifications.page.summary.title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -782,26 +810,28 @@ export const NotificationSettings: React.FC = () => {
|
||||
<Checkbox
|
||||
checked={summarizeLastMessage}
|
||||
onChange={setSummarizeLastMessage}
|
||||
ariaLabel="Summarize last message"
|
||||
ariaLabel={t('settings.notifications.page.summary.toggleAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Summarize Last Message</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.summary.toggleLabel')}</span>
|
||||
</div>
|
||||
<div className="pl-6 pb-1">
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
Requires <code className="text-[var(--primary-base)]">{'{last_message}'}</code> in the notification template.
|
||||
{t('settings.notifications.page.summary.requiresTemplateVariable')}
|
||||
{' '}
|
||||
<code className="text-[var(--primary-base)]">{'{last_message}'}</code>.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={cn("flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8")}>
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="typography-ui-label text-foreground">Summarization Model</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.summary.modelLabel')}</span>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Used for notification and voice summaries.
|
||||
{t('settings.notifications.page.summary.modelTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -812,10 +842,10 @@ export const NotificationSettings: React.FC = () => {
|
||||
onValueChange={handleUtilityModelChange}
|
||||
>
|
||||
<SelectTrigger className="w-fit min-w-[220px]">
|
||||
<SelectValue placeholder="Not selected" />
|
||||
<SelectValue placeholder={t('settings.notifications.page.summary.notSelected')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UTILITY_NOT_SELECTED_VALUE}>Not selected</SelectItem>
|
||||
<SelectItem value={UTILITY_NOT_SELECTED_VALUE}>{t('settings.notifications.page.summary.notSelected')}</SelectItem>
|
||||
{utilityModelOptions.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name}
|
||||
@@ -830,8 +860,8 @@ export const NotificationSettings: React.FC = () => {
|
||||
<>
|
||||
<div className="flex items-center gap-8 py-1.5 mt-1 border-t border-[var(--surface-subtle)]">
|
||||
<div className="flex min-w-0 flex-col w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">Threshold</span>
|
||||
<span className="typography-meta text-muted-foreground">Messages longer than this will be summarized</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.summary.thresholdLabel')}</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.notifications.page.summary.thresholdHint')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
<NumberInput
|
||||
@@ -848,8 +878,8 @@ export const NotificationSettings: React.FC = () => {
|
||||
onClick={() => setSummaryThreshold(DEFAULT_SUMMARY_THRESHOLD)}
|
||||
disabled={summaryThreshold === DEFAULT_SUMMARY_THRESHOLD}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Reset threshold"
|
||||
title="Reset"
|
||||
aria-label={t('settings.notifications.page.summary.resetThresholdAria')}
|
||||
title={t('settings.common.actions.reset')}
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -857,8 +887,8 @@ export const NotificationSettings: React.FC = () => {
|
||||
</div>
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
<div className="flex min-w-0 flex-col w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">Length</span>
|
||||
<span className="typography-meta text-muted-foreground">Target character length of the summary</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.summary.lengthLabel')}</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.notifications.page.summary.lengthHint')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
<NumberInput
|
||||
@@ -875,8 +905,8 @@ export const NotificationSettings: React.FC = () => {
|
||||
onClick={() => setSummaryLength(DEFAULT_SUMMARY_LENGTH)}
|
||||
disabled={summaryLength === DEFAULT_SUMMARY_LENGTH}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Reset summary length"
|
||||
title="Reset"
|
||||
aria-label={t('settings.notifications.page.summary.resetLengthAria')}
|
||||
title={t('settings.common.actions.reset')}
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -886,8 +916,8 @@ export const NotificationSettings: React.FC = () => {
|
||||
) : (
|
||||
<div className={cn("py-1.5 mt-1 border-t border-[var(--surface-subtle)]", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
|
||||
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "w-56 shrink-0")}>
|
||||
<span className="typography-ui-label text-foreground">Max Length</span>
|
||||
<span className="typography-meta text-muted-foreground">Truncate {'{last_message}'} to this length</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.summary.maxLengthLabel')}</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.notifications.page.summary.maxLengthHint')}</span>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2", isMobile ? "w-full" : "w-fit")}>
|
||||
<NumberInput
|
||||
@@ -904,8 +934,8 @@ export const NotificationSettings: React.FC = () => {
|
||||
onClick={() => setMaxLastMessageLength(DEFAULT_MAX_LAST_MESSAGE_LENGTH)}
|
||||
disabled={maxLastMessageLength === DEFAULT_MAX_LAST_MESSAGE_LENGTH}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Reset max message length"
|
||||
title="Reset"
|
||||
aria-label={t('settings.notifications.page.summary.resetMaxLengthAria')}
|
||||
title={t('settings.common.actions.reset')}
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -922,7 +952,7 @@ export const NotificationSettings: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Background Push Notifications
|
||||
{t('settings.notifications.page.push.title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -938,19 +968,21 @@ export const NotificationSettings: React.FC = () => {
|
||||
void handleDisableBackgroundNotifications();
|
||||
}
|
||||
}}
|
||||
ariaLabel="Enable push notifications"
|
||||
ariaLabel={t('settings.notifications.page.push.enableAria')}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className={cn("typography-ui-label", !pushSupported ? "text-muted-foreground" : "text-foreground")}>Enable push notifications</span>
|
||||
<span className={cn("typography-ui-label", !pushSupported ? "text-muted-foreground" : "text-foreground")}>
|
||||
{t('settings.notifications.page.push.enableLabel')}
|
||||
</span>
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
{!pushSupported
|
||||
? "Push not supported. Desktop Chrome/Edge and Android support push. iOS requires an installed PWA."
|
||||
: "Receive alerts via your operating system background service"}
|
||||
? t('settings.notifications.page.push.unsupportedHint')
|
||||
: t('settings.notifications.page.push.supportedHint')}
|
||||
</span>
|
||||
</div>
|
||||
{pushBusy && (
|
||||
<div className="pt-0.5 text-muted-foreground">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-current animate-busy-pulse" aria-label="Loading" />
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-current animate-busy-pulse" aria-label={t('settings.notifications.page.push.loadingAria')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { usePwaDetection } from '@/hooks/usePwaDetection';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { useI18n, type Locale } from '@/lib/i18n';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import {
|
||||
setDirectoryShowHidden,
|
||||
@@ -31,66 +32,66 @@ import {
|
||||
|
||||
interface Option<T extends string> {
|
||||
id: T;
|
||||
label: string;
|
||||
description?: string;
|
||||
labelKey: string;
|
||||
descriptionKey?: string;
|
||||
}
|
||||
|
||||
const THEME_MODE_OPTIONS: Array<{ value: ThemeMode; label: string }> = [
|
||||
const THEME_MODE_OPTIONS: Array<{ value: ThemeMode; labelKey: string }> = [
|
||||
{
|
||||
value: 'system',
|
||||
label: 'System',
|
||||
labelKey: 'settings.openchamber.visual.option.themeMode.system',
|
||||
},
|
||||
{
|
||||
value: 'light',
|
||||
label: 'Light',
|
||||
labelKey: 'settings.openchamber.visual.option.themeMode.light',
|
||||
},
|
||||
{
|
||||
value: 'dark',
|
||||
label: 'Dark',
|
||||
labelKey: 'settings.openchamber.visual.option.themeMode.dark',
|
||||
},
|
||||
];
|
||||
|
||||
const DIFF_LAYOUT_OPTIONS: Option<'dynamic' | 'inline' | 'side-by-side'>[] = [
|
||||
{
|
||||
id: 'dynamic',
|
||||
label: 'Dynamic',
|
||||
description: 'New inline, modified side-by-side.',
|
||||
labelKey: 'settings.openchamber.visual.option.diffLayout.dynamic.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.diffLayout.dynamic.description',
|
||||
},
|
||||
{
|
||||
id: 'inline',
|
||||
label: 'Always inline',
|
||||
description: 'Show as a single unified view.',
|
||||
labelKey: 'settings.openchamber.visual.option.diffLayout.inline.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.diffLayout.inline.description',
|
||||
},
|
||||
{
|
||||
id: 'side-by-side',
|
||||
label: 'Always side-by-side',
|
||||
description: 'Compare original and modified files.',
|
||||
labelKey: 'settings.openchamber.visual.option.diffLayout.sideBySide.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.diffLayout.sideBySide.description',
|
||||
},
|
||||
];
|
||||
|
||||
const DIFF_VIEW_MODE_OPTIONS: Option<'single' | 'stacked'>[] = [
|
||||
{
|
||||
id: 'single',
|
||||
label: 'Single file',
|
||||
description: 'Show one file at a time.',
|
||||
labelKey: 'settings.openchamber.visual.option.diffViewMode.single.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.diffViewMode.single.description',
|
||||
},
|
||||
{
|
||||
id: 'stacked',
|
||||
label: 'All files',
|
||||
description: 'Stack all changed files together.',
|
||||
labelKey: 'settings.openchamber.visual.option.diffViewMode.stacked.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.diffViewMode.stacked.description',
|
||||
},
|
||||
];
|
||||
|
||||
const MERMAID_RENDERING_OPTIONS: Option<'svg' | 'ascii'>[] = [
|
||||
{
|
||||
id: 'svg',
|
||||
label: 'SVG',
|
||||
description: 'Render diagrams as scalable graphics.',
|
||||
labelKey: 'settings.openchamber.visual.option.mermaidRendering.svg.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.mermaidRendering.svg.description',
|
||||
},
|
||||
{
|
||||
id: 'ascii',
|
||||
label: 'ASCII',
|
||||
description: 'Render diagrams as text blocks.',
|
||||
labelKey: 'settings.openchamber.visual.option.mermaidRendering.ascii.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.mermaidRendering.ascii.description',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -98,18 +99,18 @@ const DEFAULT_PWA_INSTALL_NAME = 'OpenChamber - AI Coding Assistant';
|
||||
const PWA_ORIENTATION_OPTIONS: Option<'system' | 'portrait' | 'landscape'>[] = [
|
||||
{
|
||||
id: 'system',
|
||||
label: 'Follow system',
|
||||
description: 'Respect the device rotation setting.',
|
||||
labelKey: 'settings.openchamber.visual.option.pwaOrientation.system.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.pwaOrientation.system.description',
|
||||
},
|
||||
{
|
||||
id: 'portrait',
|
||||
label: 'Portrait lock',
|
||||
description: 'Install the app locked to portrait.',
|
||||
labelKey: 'settings.openchamber.visual.option.pwaOrientation.portrait.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.pwaOrientation.portrait.description',
|
||||
},
|
||||
{
|
||||
id: 'landscape',
|
||||
label: 'Landscape lock',
|
||||
description: 'Install the app locked to landscape.',
|
||||
labelKey: 'settings.openchamber.visual.option.pwaOrientation.landscape.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.pwaOrientation.landscape.description',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -126,91 +127,91 @@ const normalizePwaOrientation = (value: unknown): 'system' | 'portrait' | 'lands
|
||||
const USER_MESSAGE_RENDERING_OPTIONS: Option<'markdown' | 'plain'>[] = [
|
||||
{
|
||||
id: 'markdown',
|
||||
label: 'Markdown',
|
||||
description: 'Render user text with markdown formatting.',
|
||||
labelKey: 'settings.openchamber.visual.option.userMessageRendering.markdown.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.userMessageRendering.markdown.description',
|
||||
},
|
||||
{
|
||||
id: 'plain',
|
||||
label: 'Plain text',
|
||||
description: 'Render user text with preserved whitespace and links.',
|
||||
labelKey: 'settings.openchamber.visual.option.userMessageRendering.plain.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.userMessageRendering.plain.description',
|
||||
},
|
||||
];
|
||||
|
||||
const CHAT_RENDER_MODE_OPTIONS: Option<'sorted' | 'live'>[] = [
|
||||
{
|
||||
id: 'sorted',
|
||||
label: 'Sorted',
|
||||
description: 'Render completed assistant messages without live streaming.',
|
||||
labelKey: 'settings.openchamber.visual.option.chatRenderMode.sorted.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.chatRenderMode.sorted.description',
|
||||
},
|
||||
{
|
||||
id: 'live',
|
||||
label: 'Live',
|
||||
description: 'Stream assistant text and tools as they arrive.',
|
||||
labelKey: 'settings.openchamber.visual.option.chatRenderMode.live.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.chatRenderMode.live.description',
|
||||
},
|
||||
];
|
||||
|
||||
const MESSAGE_STREAM_TRANSPORT_OPTIONS: Option<'auto' | 'ws' | 'sse'>[] = [
|
||||
{
|
||||
id: 'auto',
|
||||
label: 'Auto',
|
||||
description: 'Prefer WebSocket and fall back to SSE if needed.',
|
||||
labelKey: 'settings.openchamber.visual.option.messageTransport.auto.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.messageTransport.auto.description',
|
||||
},
|
||||
{
|
||||
id: 'ws',
|
||||
label: 'WebSocket',
|
||||
description: 'Use WebSocket for message streaming.',
|
||||
labelKey: 'settings.openchamber.visual.option.messageTransport.ws.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.messageTransport.ws.description',
|
||||
},
|
||||
{
|
||||
id: 'sse',
|
||||
label: 'SSE',
|
||||
description: 'Use Server-Sent Events for message streaming.',
|
||||
labelKey: 'settings.openchamber.visual.option.messageTransport.sse.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.messageTransport.sse.description',
|
||||
},
|
||||
];
|
||||
|
||||
const ACTIVITY_RENDER_MODE_OPTIONS: Option<'collapsed' | 'summary'>[] = [
|
||||
{
|
||||
id: 'collapsed',
|
||||
label: 'Collapsed',
|
||||
description: 'Keep Activity collapsed by default.',
|
||||
labelKey: 'settings.openchamber.visual.option.activityRenderMode.collapsed.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.activityRenderMode.collapsed.description',
|
||||
},
|
||||
{
|
||||
id: 'summary',
|
||||
label: 'Expanded',
|
||||
description: 'Expand Activity by default.',
|
||||
labelKey: 'settings.openchamber.visual.option.activityRenderMode.summary.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.activityRenderMode.summary.description',
|
||||
},
|
||||
];
|
||||
|
||||
const TIME_FORMAT_OPTIONS: Option<'auto' | '12h' | '24h'>[] = [
|
||||
{
|
||||
id: 'auto',
|
||||
label: 'Auto',
|
||||
description: 'Use system locale preference.',
|
||||
labelKey: 'settings.openchamber.visual.option.timeFormat.auto.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.timeFormat.auto.description',
|
||||
},
|
||||
{
|
||||
id: '24h',
|
||||
label: '24-hour',
|
||||
description: 'Show time as 14:15.',
|
||||
labelKey: 'settings.openchamber.visual.option.timeFormat.24h.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.timeFormat.24h.description',
|
||||
},
|
||||
{
|
||||
id: '12h',
|
||||
label: '12-hour',
|
||||
description: 'Show time as 02:15 PM.',
|
||||
labelKey: 'settings.openchamber.visual.option.timeFormat.12h.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.timeFormat.12h.description',
|
||||
},
|
||||
];
|
||||
|
||||
const WEEK_START_OPTIONS: Option<'auto' | 'monday' | 'sunday'>[] = [
|
||||
{
|
||||
id: 'auto',
|
||||
label: 'Auto',
|
||||
description: 'Use locale week start.',
|
||||
labelKey: 'settings.openchamber.visual.option.weekStart.auto.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.weekStart.auto.description',
|
||||
},
|
||||
{
|
||||
id: 'monday',
|
||||
label: 'Monday',
|
||||
labelKey: 'settings.openchamber.visual.option.weekStart.monday.label',
|
||||
},
|
||||
{
|
||||
id: 'sunday',
|
||||
label: 'Sunday',
|
||||
labelKey: 'settings.openchamber.visual.option.weekStart.sunday.label',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -226,6 +227,8 @@ interface OpenChamberVisualSettingsProps {
|
||||
}
|
||||
|
||||
export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps> = ({ visibleSettings }) => {
|
||||
const { locale, locales, setLocale, label, t } = useI18n();
|
||||
const tUnsafe = React.useCallback((key: string) => t(key as Parameters<typeof t>[0]), [t]);
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const { browserTab } = usePwaDetection();
|
||||
const directoryShowHidden = useDirectoryShowHidden();
|
||||
@@ -464,6 +467,18 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
const showPwaOrientationSetting = shouldShow('pwaOrientation') && isWebRuntime() && !isDesktopShell() && !isVSCode;
|
||||
const [pwaInstallName, setPwaInstallName] = React.useState('');
|
||||
const [pwaOrientation, setPwaOrientation] = React.useState<'system' | 'portrait' | 'landscape'>('system');
|
||||
const selectedTimeFormatLabel = React.useMemo(() => {
|
||||
const option = TIME_FORMAT_OPTIONS.find((item) => item.id === timeFormatPreference);
|
||||
return tUnsafe(option?.labelKey ?? 'settings.openchamber.visual.option.timeFormat.auto.label');
|
||||
}, [timeFormatPreference, tUnsafe]);
|
||||
const selectedWeekStartLabel = React.useMemo(() => {
|
||||
const option = WEEK_START_OPTIONS.find((item) => item.id === weekStartPreference);
|
||||
return tUnsafe(option?.labelKey ?? 'settings.openchamber.visual.option.weekStart.auto.label');
|
||||
}, [weekStartPreference, tUnsafe]);
|
||||
const selectedPwaOrientationLabel = React.useMemo(() => {
|
||||
const option = PWA_ORIENTATION_OPTIONS.find((item) => item.id === pwaOrientation);
|
||||
return option ? tUnsafe(option.labelKey) : undefined;
|
||||
}, [pwaOrientation, tUnsafe]);
|
||||
|
||||
const applyPwaInstallName = React.useCallback(async (value: string) => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -570,7 +585,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
|
||||
<div className="pb-1.5">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<span className="typography-ui-header font-medium text-foreground">Color Mode</span>
|
||||
<span className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.colorMode')}</span>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{THEME_MODE_OPTIONS.map((option) => (
|
||||
<Button
|
||||
@@ -578,22 +593,41 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
variant="chip"
|
||||
size="xs"
|
||||
aria-pressed={themeMode === option.value}
|
||||
className="!font-normal"
|
||||
onClick={() => setThemeMode(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
className="!font-normal"
|
||||
onClick={() => setThemeMode(option.value)}
|
||||
>
|
||||
{tUnsafe(option.labelKey)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-1 gap-2 py-1.5 md:grid-cols-[14rem_auto] md:gap-x-8 md:gap-y-2">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="typography-ui-label text-foreground shrink-0">{t('settings.appearance.language.label')}</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.appearance.language.description')}</span>
|
||||
</div>
|
||||
<Select value={locale} onValueChange={(value) => setLocale(value as Locale)}>
|
||||
<SelectTrigger aria-label={t('settings.appearance.language.select')} className="w-fit">
|
||||
<SelectValue>{label(locale)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{locales.map((availableLocale) => (
|
||||
<SelectItem key={availableLocale} value={availableLocale}>
|
||||
{label(availableLocale)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-1 gap-2 py-1.5 md:grid-cols-[14rem_auto] md:gap-x-8 md:gap-y-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="typography-ui-label text-foreground shrink-0">Light Theme</span>
|
||||
<span className="typography-ui-label text-foreground shrink-0">{t('settings.openchamber.visual.field.lightTheme')}</span>
|
||||
<Select value={selectedLightTheme?.metadata.id ?? ''} onValueChange={setLightThemePreference}>
|
||||
<SelectTrigger aria-label="Select light theme" className="w-fit">
|
||||
<SelectValue placeholder="Select theme">
|
||||
<SelectTrigger aria-label={t('settings.openchamber.visual.field.selectLightThemeAria')} className="w-fit">
|
||||
<SelectValue placeholder={t('settings.openchamber.visual.field.selectThemePlaceholder')}>
|
||||
{selectedLightTheme
|
||||
? formatThemeLabel(selectedLightTheme.metadata.name, 'light')
|
||||
: undefined}
|
||||
@@ -609,10 +643,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="typography-ui-label text-foreground shrink-0">Dark Theme</span>
|
||||
<span className="typography-ui-label text-foreground shrink-0">{t('settings.openchamber.visual.field.darkTheme')}</span>
|
||||
<Select value={selectedDarkTheme?.metadata.id ?? ''} onValueChange={setDarkThemePreference}>
|
||||
<SelectTrigger aria-label="Select dark theme" className="w-fit">
|
||||
<SelectValue placeholder="Select theme">
|
||||
<SelectTrigger aria-label={t('settings.openchamber.visual.field.selectDarkThemeAria')} className="w-fit">
|
||||
<SelectValue placeholder={t('settings.openchamber.visual.field.selectThemePlaceholder')}>
|
||||
{selectedDarkTheme
|
||||
? formatThemeLabel(selectedDarkTheme.metadata.name, 'dark')
|
||||
: undefined}
|
||||
@@ -633,14 +667,14 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<div className="mt-1 grid grid-cols-1 gap-2 py-1.5 md:grid-cols-[14rem_auto] md:gap-x-8 md:gap-y-2">
|
||||
{shouldShow('timeFormat') && (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="typography-ui-label text-foreground shrink-0">Time Format</span>
|
||||
<span className="typography-ui-label text-foreground shrink-0">{t('settings.openchamber.visual.field.timeFormat')}</span>
|
||||
<Select value={timeFormatPreference} onValueChange={(value: 'auto' | '12h' | '24h') => handleTimeFormatPreferenceChange(value)}>
|
||||
<SelectTrigger aria-label="Select time format" className="w-fit">
|
||||
<SelectValue />
|
||||
<SelectTrigger aria-label={t('settings.openchamber.visual.field.selectTimeFormatAria')} className="w-fit">
|
||||
<SelectValue>{selectedTimeFormatLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_FORMAT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>{option.label}</SelectItem>
|
||||
<SelectItem key={option.id} value={option.id}>{tUnsafe(option.labelKey)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -649,14 +683,14 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
|
||||
{shouldShow('weekStart') && (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="typography-ui-label text-foreground shrink-0">Week Starts On</span>
|
||||
<span className="typography-ui-label text-foreground shrink-0">{t('settings.openchamber.visual.field.weekStartsOn')}</span>
|
||||
<Select value={weekStartPreference} onValueChange={(value: 'auto' | 'monday' | 'sunday') => handleWeekStartPreferenceChange(value)}>
|
||||
<SelectTrigger aria-label="Select week start" className="w-fit">
|
||||
<SelectValue />
|
||||
<SelectTrigger aria-label={t('settings.openchamber.visual.field.selectWeekStartAria')} className="w-fit">
|
||||
<SelectValue>{selectedWeekStartLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WEEK_START_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>{option.label}</SelectItem>
|
||||
<SelectItem key={option.id} value={option.id}>{tUnsafe(option.labelKey)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -685,20 +719,20 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
}}
|
||||
className="inline-flex items-center typography-ui-label font-normal text-foreground underline decoration-[1px] underline-offset-2 hover:text-foreground/80 disabled:cursor-not-allowed disabled:text-muted-foreground/60"
|
||||
>
|
||||
{themesReloading ? 'Reloading themes...' : 'Reload themes'}
|
||||
{themesReloading ? t('settings.openchamber.visual.actions.reloadingThemes') : t('settings.openchamber.visual.actions.reloadThemes')}
|
||||
</button>
|
||||
<Tooltip delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center rounded-md p-1 text-muted-foreground/70 hover:text-foreground"
|
||||
aria-label="Theme import info"
|
||||
aria-label={t('settings.openchamber.visual.field.themeImportInfoAria')}
|
||||
>
|
||||
<RiInformationLine className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8}>
|
||||
Import custom themes from ~/.config/openchamber/themes/
|
||||
{t('settings.openchamber.visual.field.themeImportInfoTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -706,8 +740,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
{showPwaInstallNameSetting && (
|
||||
<div className="py-1.5 space-y-1.5">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="typography-ui-label text-foreground">Install App Name</span>
|
||||
<span className="typography-meta text-muted-foreground">Used by PWA installation process.</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.installAppName')}</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.openchamber.visual.field.installAppNameHint')}</span>
|
||||
</div>
|
||||
<div className="flex w-full max-w-[28rem] items-center gap-2">
|
||||
<Input
|
||||
@@ -726,7 +760,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
}}
|
||||
className="h-7"
|
||||
maxLength={64}
|
||||
aria-label="PWA install app name"
|
||||
aria-label={t('settings.openchamber.visual.field.pwaInstallAppNameAria')}
|
||||
/>
|
||||
<Button size="sm"
|
||||
type="button"
|
||||
@@ -736,8 +770,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
void applyPwaInstallName('');
|
||||
}}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Reset install app name"
|
||||
title="Reset"
|
||||
aria-label={t('settings.openchamber.visual.actions.resetInstallAppNameAria')}
|
||||
title={t('settings.common.actions.reset')}
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -748,8 +782,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
{showPwaOrientationSetting && (
|
||||
<div className="py-1.5 space-y-1.5">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="typography-ui-label text-foreground">Install Orientation</span>
|
||||
<span className="typography-meta text-muted-foreground">Used by the installed web app. Reinstall the PWA after changing this.</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.installOrientation')}</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.openchamber.visual.field.installOrientationHint')}</span>
|
||||
</div>
|
||||
<div className="flex w-full max-w-[18rem] items-center gap-2">
|
||||
<Select
|
||||
@@ -760,13 +794,15 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
void applyPwaOrientation(orientation);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label="PWA install orientation" className="w-full">
|
||||
<SelectValue placeholder="Select orientation" />
|
||||
<SelectTrigger aria-label={t('settings.openchamber.visual.field.pwaInstallOrientationAria')} className="w-full">
|
||||
<SelectValue placeholder={t('settings.openchamber.visual.field.selectOrientationPlaceholder')}>
|
||||
{selectedPwaOrientationLabel}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PWA_ORIENTATION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
{option.label}
|
||||
{tUnsafe(option.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -780,8 +816,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
}}
|
||||
disabled={pwaOrientation === 'system'}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Reset install orientation"
|
||||
title="Reset"
|
||||
aria-label={t('settings.openchamber.visual.actions.resetInstallOrientationAria')}
|
||||
title={t('settings.common.actions.reset')}
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -796,13 +832,13 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
{hasLayoutSettings && (
|
||||
<div className="mb-8 space-y-3">
|
||||
<section className="p-2 space-y-0.5">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">Spacing & Layout</h4>
|
||||
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.spacingAndLayout')}</h4>
|
||||
<div className="pl-2">
|
||||
|
||||
{shouldShow('fontSize') && !isMobile && (
|
||||
<div className="flex items-center gap-8 py-1">
|
||||
<div className="flex min-w-0 flex-col w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">Interface Font Size</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.interfaceFontSize')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
<NumberInput
|
||||
@@ -811,7 +847,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
min={50}
|
||||
max={200}
|
||||
step={5}
|
||||
aria-label="Font size percentage"
|
||||
aria-label={t('settings.openchamber.visual.field.fontSizePercentageAria')}
|
||||
className="w-16"
|
||||
/>
|
||||
<Button size="sm"
|
||||
@@ -820,8 +856,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
onClick={() => setFontSize(100)}
|
||||
disabled={fontSize === 100}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Reset font size"
|
||||
title="Reset"
|
||||
aria-label={t('settings.openchamber.visual.actions.resetFontSizeAria')}
|
||||
title={t('settings.common.actions.reset')}
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -832,7 +868,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
{shouldShow('terminalFontSize') && (
|
||||
<div className={cn("py-1", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
|
||||
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "w-56 shrink-0")}>
|
||||
<span className="typography-ui-label text-foreground">Terminal Font Size</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.terminalFontSize')}</span>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2", isMobile ? "w-full" : "w-fit")}>
|
||||
<NumberInput
|
||||
@@ -849,8 +885,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
onClick={() => setTerminalFontSize(13)}
|
||||
disabled={terminalFontSize === 13}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Reset terminal font size"
|
||||
title="Reset"
|
||||
aria-label={t('settings.openchamber.visual.actions.resetTerminalFontSizeAria')}
|
||||
title={t('settings.common.actions.reset')}
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -861,7 +897,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
{shouldShow('spacing') && (
|
||||
<div className={cn("py-1", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
|
||||
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "w-56 shrink-0")}>
|
||||
<span className="typography-ui-label text-foreground">Spacing Density</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.spacingDensity')}</span>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2", isMobile ? "w-full" : "w-fit")}>
|
||||
<NumberInput
|
||||
@@ -878,8 +914,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
onClick={() => setPadding(100)}
|
||||
disabled={padding === 100}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Reset spacing"
|
||||
title="Reset"
|
||||
aria-label={t('settings.openchamber.visual.actions.resetSpacingAria')}
|
||||
title={t('settings.common.actions.reset')}
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -891,13 +927,13 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<div className={cn("py-1", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
|
||||
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "w-56 shrink-0")}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="typography-ui-label text-foreground">Input Bar Offset</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.inputBarOffset')}</span>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Raise input bar to avoid OS-level screen obstructions like home bars.
|
||||
{t('settings.openchamber.visual.field.inputBarOffsetTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -917,8 +953,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
onClick={() => setInputBarOffset(0)}
|
||||
disabled={inputBarOffset === 0}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Reset input bar offset"
|
||||
title="Reset"
|
||||
aria-label={t('settings.openchamber.visual.actions.resetInputBarOffsetAria')}
|
||||
title={t('settings.common.actions.reset')}
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -936,7 +972,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
{hasNavigationSettings && (
|
||||
<div className="space-y-3">
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">Navigation</h4>
|
||||
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.navigation')}</h4>
|
||||
{shouldShow('terminalQuickKeys') && !isMobile && (
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
||||
@@ -954,16 +990,16 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Checkbox
|
||||
checked={showTerminalQuickKeysOnDesktop}
|
||||
onChange={setShowTerminalQuickKeysOnDesktop}
|
||||
ariaLabel="Terminal quick keys"
|
||||
ariaLabel={t('settings.openchamber.visual.field.terminalQuickKeysAria')}
|
||||
/>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="typography-ui-label text-foreground">Terminal Quick Keys</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.terminalQuickKeys')}</span>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Show Esc, Ctrl, Arrows in terminal view
|
||||
{t('settings.openchamber.visual.field.terminalQuickKeysTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -982,8 +1018,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<div className="grid grid-cols-1 gap-y-2 md:grid-cols-[minmax(0,16rem)_minmax(0,16rem)] md:justify-start md:gap-x-2">
|
||||
{shouldShow('chatRenderMode') && (
|
||||
<section className="p-2 md:col-span-2">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">Chat Render Mode</h4>
|
||||
<div role="radiogroup" aria-label="Chat render mode" className="mt-1 grid w-full max-w-[26rem] grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.chatRenderMode')}</h4>
|
||||
<div role="radiogroup" aria-label={t('settings.openchamber.visual.section.chatRenderModeAria')} className="mt-1 grid w-full max-w-[26rem] grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{CHAT_RENDER_MODE_OPTIONS.map((option) => {
|
||||
const selected = chatRenderMode === option.id;
|
||||
const previewPhase = chatRenderPreviewTick % 12;
|
||||
@@ -1001,7 +1037,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
)}
|
||||
>
|
||||
<span className={cn('typography-ui-label', selected ? 'text-foreground' : 'text-muted-foreground')}>
|
||||
{option.label}
|
||||
{tUnsafe(option.labelKey)}
|
||||
</span>
|
||||
<div className="mt-2 w-full rounded-md border border-border/60 bg-muted/30 p-2">
|
||||
{option.id === 'live' ? (
|
||||
@@ -1066,7 +1102,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
|
||||
{shouldShow('messageTransport') && (
|
||||
<section className="p-2 md:col-span-2">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">Message Stream Transport</h4>
|
||||
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.messageStreamTransport')}</h4>
|
||||
<div className="mt-1 flex max-w-[24rem] flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{MESSAGE_STREAM_TRANSPORT_OPTIONS.map((option) => (
|
||||
@@ -1078,12 +1114,15 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
className="!font-normal"
|
||||
onClick={() => handleMessageStreamTransportChange(option.id)}
|
||||
>
|
||||
{option.label}
|
||||
{tUnsafe(option.labelKey)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
{MESSAGE_STREAM_TRANSPORT_OPTIONS.find((option) => option.id === messageStreamTransport)?.description}
|
||||
{(() => {
|
||||
const option = MESSAGE_STREAM_TRANSPORT_OPTIONS.find((item) => item.id === messageStreamTransport);
|
||||
return option?.descriptionKey ? tUnsafe(option.descriptionKey) : '';
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1091,8 +1130,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
|
||||
{shouldShow('activityRenderMode') && chatRenderMode === 'sorted' && (
|
||||
<section className="p-2 md:col-span-2">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">Activity Default</h4>
|
||||
<div role="radiogroup" aria-label="Activity default mode" className="mt-0.5 space-y-0">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.activityDefault')}</h4>
|
||||
<div role="radiogroup" aria-label={t('settings.openchamber.visual.section.activityDefaultAria')} className="mt-0.5 space-y-0">
|
||||
{ACTIVITY_RENDER_MODE_OPTIONS.map((option) => {
|
||||
const selected = activityRenderMode === option.id;
|
||||
return (
|
||||
@@ -1113,10 +1152,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Radio
|
||||
checked={selected}
|
||||
onChange={() => handleActivityRenderModeChange(option.id)}
|
||||
ariaLabel={`Activity default mode: ${option.label}`}
|
||||
ariaLabel={t('settings.openchamber.visual.field.activityDefaultModeAria', { option: tUnsafe(option.labelKey) })}
|
||||
/>
|
||||
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
|
||||
{option.label}
|
||||
{tUnsafe(option.labelKey)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -1127,7 +1166,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
|
||||
{shouldShow('expandedTools') && (
|
||||
<section className="p-2 md:col-span-2 space-y-0.5">
|
||||
<div className="typography-ui-header font-medium text-foreground py-1.5">Show tools opened by default:</div>
|
||||
<div className="typography-ui-header font-medium text-foreground py-1.5">{t('settings.openchamber.visual.section.showToolsOpenedByDefault')}</div>
|
||||
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-0.5"
|
||||
@@ -1145,9 +1184,9 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Checkbox
|
||||
checked={showExpandedBashTools}
|
||||
onChange={handleShowExpandedBashToolsChange}
|
||||
ariaLabel="Show expanded bash tools"
|
||||
ariaLabel={t('settings.openchamber.visual.field.showExpandedBashToolsAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Bash</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.bash')}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -1166,17 +1205,17 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Checkbox
|
||||
checked={showExpandedEditTools}
|
||||
onChange={handleShowExpandedEditToolsChange}
|
||||
ariaLabel="Show expanded edit tools"
|
||||
ariaLabel={t('settings.openchamber.visual.field.showExpandedEditToolsAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Edit tools</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.editTools')}</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{shouldShow('userMessageRendering') && (
|
||||
<section className="p-2">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">User Message Rendering</h4>
|
||||
<div role="radiogroup" aria-label="User message rendering mode" className="mt-0.5 space-y-0">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.userMessageRendering')}</h4>
|
||||
<div role="radiogroup" aria-label={t('settings.openchamber.visual.section.userMessageRenderingAria')} className="mt-0.5 space-y-0">
|
||||
{USER_MESSAGE_RENDERING_OPTIONS.map((option) => {
|
||||
const selected = normalizeUserMessageRenderingMode(userMessageRenderingMode) === option.id;
|
||||
return (
|
||||
@@ -1197,10 +1236,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Radio
|
||||
checked={selected}
|
||||
onChange={() => handleUserMessageRenderingModeChange(option.id)}
|
||||
ariaLabel={`User message rendering: ${option.label}`}
|
||||
ariaLabel={t('settings.openchamber.visual.field.userMessageRenderingAria', { option: tUnsafe(option.labelKey) })}
|
||||
/>
|
||||
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
|
||||
{option.label}
|
||||
{tUnsafe(option.labelKey)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -1211,8 +1250,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
|
||||
{shouldShow('mermaidRendering') && (
|
||||
<section className="p-2">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">Mermaid Rendering</h4>
|
||||
<div role="radiogroup" aria-label="Mermaid rendering mode" className="mt-0.5 space-y-0">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.mermaidRendering')}</h4>
|
||||
<div role="radiogroup" aria-label={t('settings.openchamber.visual.section.mermaidRenderingAria')} className="mt-0.5 space-y-0">
|
||||
{MERMAID_RENDERING_OPTIONS.map((option) => {
|
||||
const selected = mermaidRenderingMode === option.id;
|
||||
return (
|
||||
@@ -1233,10 +1272,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Radio
|
||||
checked={selected}
|
||||
onChange={() => handleMermaidRenderingModeChange(option.id)}
|
||||
ariaLabel={`Mermaid rendering: ${option.label}`}
|
||||
ariaLabel={t('settings.openchamber.visual.field.mermaidRenderingAria', { option: tUnsafe(option.labelKey) })}
|
||||
/>
|
||||
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
|
||||
{option.label}
|
||||
{tUnsafe(option.labelKey)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -1247,8 +1286,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
|
||||
{shouldShow('diffLayout') && !isVSCode && (
|
||||
<section className="p-2">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">Diff Layout</h4>
|
||||
<div role="radiogroup" aria-label="Diff layout" className="mt-0.5 space-y-0">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.diffLayout')}</h4>
|
||||
<div role="radiogroup" aria-label={t('settings.openchamber.visual.section.diffLayoutAria')} className="mt-0.5 space-y-0">
|
||||
{DIFF_LAYOUT_OPTIONS.map((option) => {
|
||||
const selected = diffLayoutPreference === option.id;
|
||||
return (
|
||||
@@ -1269,10 +1308,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Radio
|
||||
checked={selected}
|
||||
onChange={() => setDiffLayoutPreference(option.id)}
|
||||
ariaLabel={`Diff layout: ${option.label}`}
|
||||
ariaLabel={t('settings.openchamber.visual.field.diffLayoutAria', { option: tUnsafe(option.labelKey) })}
|
||||
/>
|
||||
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
|
||||
{option.label}
|
||||
{tUnsafe(option.labelKey)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -1283,8 +1322,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
|
||||
{shouldShow('diffLayout') && !isVSCode && (
|
||||
<section className="p-2">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">Diff View Mode</h4>
|
||||
<div role="radiogroup" aria-label="Diff view mode" className="mt-0.5 space-y-0">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.diffViewMode')}</h4>
|
||||
<div role="radiogroup" aria-label={t('settings.openchamber.visual.section.diffViewModeAria')} className="mt-0.5 space-y-0">
|
||||
{DIFF_VIEW_MODE_OPTIONS.map((option) => {
|
||||
const selected = diffViewMode === option.id;
|
||||
return (
|
||||
@@ -1305,10 +1344,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Radio
|
||||
checked={selected}
|
||||
onChange={() => setDiffViewMode(option.id)}
|
||||
ariaLabel={`Diff view mode: ${option.label}`}
|
||||
ariaLabel={t('settings.openchamber.visual.field.diffViewModeAria', { option: tUnsafe(option.labelKey) })}
|
||||
/>
|
||||
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
|
||||
{option.label}
|
||||
{tUnsafe(option.labelKey)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -1338,9 +1377,9 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Checkbox
|
||||
checked={showReasoningTraces}
|
||||
onChange={setShowReasoningTraces}
|
||||
ariaLabel="Show reasoning traces"
|
||||
ariaLabel={t('settings.openchamber.visual.field.showReasoningTracesAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Show Reasoning Traces</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.showReasoningTraces')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1361,9 +1400,9 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Checkbox
|
||||
checked={stickyUserHeader}
|
||||
onChange={handleStickyUserHeaderChange}
|
||||
ariaLabel="Sticky user header"
|
||||
ariaLabel={t('settings.openchamber.visual.field.stickyUserHeaderAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Sticky User Header</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.stickyUserHeader')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1384,9 +1423,9 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Checkbox
|
||||
checked={showToolFileIcons}
|
||||
onChange={handleShowToolFileIconsChange}
|
||||
ariaLabel="Show tool file icons"
|
||||
ariaLabel={t('settings.openchamber.visual.field.showToolFileIconsAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Show Tool File Icons</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.showToolFileIcons')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1407,9 +1446,9 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Checkbox
|
||||
checked={showMobileSessionStatusBar}
|
||||
onChange={setShowMobileSessionStatusBar}
|
||||
ariaLabel="Show mobile status bar"
|
||||
ariaLabel={t('settings.openchamber.visual.field.showMobileStatusBarAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Show Mobile Status Bar</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.showMobileStatusBar')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1430,9 +1469,9 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Checkbox
|
||||
checked={directoryShowHidden}
|
||||
onChange={setDirectoryShowHidden}
|
||||
ariaLabel="Show dotfiles"
|
||||
ariaLabel={t('settings.openchamber.visual.field.showDotfilesAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Show Dotfiles</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.showDotfiles')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1453,16 +1492,16 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Checkbox
|
||||
checked={queueModeEnabled}
|
||||
onChange={setQueueMode}
|
||||
ariaLabel="Queue messages by default"
|
||||
ariaLabel={t('settings.openchamber.visual.field.queueMessagesByDefaultAria')}
|
||||
/>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="typography-ui-label text-foreground">Queue Messages by Default</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.queueMessagesByDefault')}</span>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
When enabled, Enter queues messages. Use {getModifierLabel()}+Enter to send.
|
||||
{t('settings.openchamber.visual.field.queueMessagesByDefaultTooltip', { modifier: getModifierLabel() })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -1486,9 +1525,9 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Checkbox
|
||||
checked={persistChatDraft}
|
||||
onChange={setPersistChatDraft}
|
||||
ariaLabel="Persist draft messages"
|
||||
ariaLabel={t('settings.openchamber.visual.field.persistDraftMessagesAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Persist Draft Messages</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.persistDraftMessages')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1509,9 +1548,9 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Checkbox
|
||||
checked={inputSpellcheckEnabled}
|
||||
onChange={handleInputSpellcheckChange}
|
||||
ariaLabel="Enable spellcheck in text inputs"
|
||||
ariaLabel={t('settings.openchamber.visual.field.enableSpellcheckInTextInputsAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Enable Spellcheck in Text Inputs</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.enableSpellcheckInTextInputs')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1525,12 +1564,12 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
{shouldShow('reportUsage') && (
|
||||
<div className="space-y-3">
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
<h4 className="typography-ui-header font-medium text-foreground mb-2">Privacy</h4>
|
||||
<h4 className="typography-ui-header font-medium text-foreground mb-2">{t('settings.openchamber.visual.section.privacy')}</h4>
|
||||
<div className="flex items-start gap-2 py-1.5">
|
||||
<Checkbox
|
||||
checked={reportUsage}
|
||||
onChange={handleReportUsageChange}
|
||||
ariaLabel="Send anonymous usage reports"
|
||||
ariaLabel={t('settings.openchamber.visual.field.sendAnonymousUsageReportsAria')}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div
|
||||
@@ -1546,10 +1585,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="typography-ui-label text-foreground">Send anonymous usage reports</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.sendAnonymousUsageReports')}</span>
|
||||
</div>
|
||||
<span className="typography-meta text-muted-foreground pointer-events-none">
|
||||
Helps us understand which app versions are actively used so we can prioritize improvements. Only app version, platform, and runtime are collected - no personal data or code.
|
||||
{t('settings.openchamber.visual.field.sendAnonymousUsageReportsHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,10 @@ import { RiFolderLine, RiInformationLine } from '@remixicon/react';
|
||||
import { isDesktopShell, isTauriShell } from '@/lib/desktop';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export const OpenCodeCliSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [value, setValue] = React.useState('');
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
@@ -58,7 +60,7 @@ export const OpenCodeCliSettings: React.FC = () => {
|
||||
|
||||
try {
|
||||
const selected = await tauri.dialog.open({
|
||||
title: 'Select opencode binary',
|
||||
title: t('settings.openchamber.opencodeCli.dialog.selectBinaryTitle'),
|
||||
multiple: false,
|
||||
directory: false,
|
||||
});
|
||||
@@ -68,31 +70,38 @@ export const OpenCodeCliSettings: React.FC = () => {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleSaveAndReload = React.useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updateDesktopSettings({ opencodeBinary: value.trim() });
|
||||
await reloadOpenCodeConfiguration({ message: 'Restarting OpenCode…', mode: 'projects', scopes: ['all'] });
|
||||
await reloadOpenCodeConfiguration({
|
||||
message: t('settings.openchamber.opencodeCli.actions.restartingOpenCode'),
|
||||
mode: 'projects',
|
||||
scopes: ['all'],
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [value]);
|
||||
}, [t, value]);
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
OpenCode CLI
|
||||
{t('settings.openchamber.opencodeCli.title')}
|
||||
</h3>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Optional absolute path to the <code className="font-mono text-xs">opencode</code> binary.
|
||||
{t('settings.openchamber.opencodeCli.tooltipPrefix')}
|
||||
{' '}
|
||||
<code className="font-mono text-xs">opencode</code>
|
||||
{t('settings.openchamber.opencodeCli.tooltipSuffix')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -101,13 +110,13 @@ export const OpenCodeCliSettings: React.FC = () => {
|
||||
<section className="px-2 pb-2 pt-0 space-y-0.5">
|
||||
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-3">
|
||||
<div className="flex min-w-0 flex-col shrink-0">
|
||||
<span className="typography-ui-label text-foreground">OpenCode Binary Path</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.opencodeCli.field.binaryPath')}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2 sm:w-[20rem]">
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="/Users/you/.bun/bin/opencode"
|
||||
placeholder={t('settings.openchamber.opencodeCli.field.binaryPathPlaceholder')}
|
||||
disabled={isLoading || isSaving}
|
||||
className="h-7 min-w-0 flex-1 font-mono text-xs"
|
||||
/>
|
||||
@@ -118,8 +127,8 @@ export const OpenCodeCliSettings: React.FC = () => {
|
||||
onClick={handleBrowse}
|
||||
disabled={isLoading || isSaving || !isDesktopShell() || !isTauriShell()}
|
||||
className="h-7 w-7 p-0"
|
||||
aria-label="Browse for OpenCode binary path"
|
||||
title="Browse"
|
||||
aria-label={t('settings.openchamber.opencodeCli.actions.browseAria')}
|
||||
title={t('settings.openchamber.opencodeCli.actions.browse')}
|
||||
>
|
||||
<RiFolderLine className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -128,7 +137,14 @@ export const OpenCodeCliSettings: React.FC = () => {
|
||||
|
||||
<div className="py-1.5">
|
||||
<div className="typography-micro text-muted-foreground/70">
|
||||
Tip: you can also use <span className="font-mono">OPENCODE_BINARY</span> env var, but this setting persists in <span className="font-mono">~/.config/openchamber/settings.json</span>.
|
||||
{t('settings.openchamber.opencodeCli.tipPrefix')}
|
||||
{' '}
|
||||
<span className="font-mono">OPENCODE_BINARY</span>
|
||||
{' '}
|
||||
{t('settings.openchamber.opencodeCli.tipMiddle')}
|
||||
{' '}
|
||||
<span className="font-mono">~/.config/openchamber/settings.json</span>
|
||||
{'.'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -140,7 +156,7 @@ export const OpenCodeCliSettings: React.FC = () => {
|
||||
disabled={isLoading || isSaving}
|
||||
className="shrink-0 !font-normal"
|
||||
>
|
||||
{isSaving ? 'Saving…' : 'Save + Reload'}
|
||||
{isSaving ? t('settings.common.actions.saving') : t('settings.openchamber.opencodeCli.actions.saveAndReload')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -14,10 +14,11 @@ import {
|
||||
type PasskeyStatus,
|
||||
type StoredPasskey,
|
||||
} from '@/lib/passkeys';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const formatTimestamp = (timestamp: number | null) => {
|
||||
const formatTimestamp = (timestamp: number | null, neverUsedText: string) => {
|
||||
if (!timestamp || !Number.isFinite(timestamp)) {
|
||||
return 'Never used';
|
||||
return neverUsedText;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
@@ -27,6 +28,7 @@ const formatTimestamp = (timestamp: number | null) => {
|
||||
};
|
||||
|
||||
export const PasskeySettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [supportsPasskeys, setSupportsPasskeys] = React.useState(false);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const [isRegistering, setIsRegistering] = React.useState(false);
|
||||
@@ -45,12 +47,12 @@ export const PasskeySettings: React.FC = () => {
|
||||
const nextPasskeys = await fetchStoredPasskeys();
|
||||
setPasskeys(nextPasskeys);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Could not load passkeys.';
|
||||
const message = error instanceof Error ? error.message : t('settings.openchamber.passkeys.toast.loadFailed');
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -93,7 +95,7 @@ export const PasskeySettings: React.FC = () => {
|
||||
|
||||
const handleRegisterPasskey = React.useCallback(async () => {
|
||||
if (!status.enabled) {
|
||||
const message = 'Enable the UI password lock before adding passkeys.';
|
||||
const message = t('settings.openchamber.passkeys.toast.enableUiPasswordFirst');
|
||||
setErrorMessage(message);
|
||||
toast.message(message);
|
||||
return;
|
||||
@@ -118,20 +120,20 @@ export const PasskeySettings: React.FC = () => {
|
||||
await registerCurrentDevicePasskey();
|
||||
setStatus(await fetchPasskeyStatus());
|
||||
await loadPasskeys();
|
||||
toast.success('Passkey added');
|
||||
toast.success(t('settings.openchamber.passkeys.toast.added'));
|
||||
} catch (error) {
|
||||
if (isPasskeyCeremonyAbort(error)) {
|
||||
toast.message('Passkey setup canceled');
|
||||
toast.message(t('settings.openchamber.passkeys.toast.setupCanceled'));
|
||||
return;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : 'Could not add passkey.';
|
||||
const message = error instanceof Error ? error.message : t('settings.openchamber.passkeys.toast.addFailed');
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsRegistering(false);
|
||||
}
|
||||
}, [isRegistering, loadPasskeys, status.enabled, supportState.reason, supportsPasskeys]);
|
||||
}, [isRegistering, loadPasskeys, status.enabled, supportState.reason, supportsPasskeys, t]);
|
||||
|
||||
const handleRevokePasskey = React.useCallback(async (id: string) => {
|
||||
setRevokingId(id);
|
||||
@@ -141,15 +143,15 @@ export const PasskeySettings: React.FC = () => {
|
||||
await revokeStoredPasskey(id);
|
||||
setStatus(await fetchPasskeyStatus());
|
||||
await loadPasskeys();
|
||||
toast.success('Passkey removed');
|
||||
toast.success(t('settings.openchamber.passkeys.toast.removed'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Could not remove passkey.';
|
||||
const message = error instanceof Error ? error.message : t('settings.openchamber.passkeys.toast.removeFailed');
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setRevokingId(null);
|
||||
}
|
||||
}, [loadPasskeys]);
|
||||
}, [loadPasskeys, t]);
|
||||
|
||||
const handleResetAllAuth = React.useCallback(async () => {
|
||||
setIsResetting(true);
|
||||
@@ -159,23 +161,23 @@ export const PasskeySettings: React.FC = () => {
|
||||
await resetAllAuth();
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Could not clear saved authentication.';
|
||||
const message = error instanceof Error ? error.message : t('settings.openchamber.passkeys.toast.clearAuthFailed');
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
setIsResetting(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Passkeys</h3>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.passkeys.title')}</h3>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-2">
|
||||
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">Current device</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.passkeys.field.currentDevice')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:w-fit">
|
||||
<Button
|
||||
@@ -186,7 +188,7 @@ export const PasskeySettings: React.FC = () => {
|
||||
disabled={isLoading || isResetting}
|
||||
className="!font-normal"
|
||||
>
|
||||
{isRegistering ? 'Cancel passkey setup' : 'Add passkey'}
|
||||
{isRegistering ? t('settings.openchamber.passkeys.actions.cancelSetup') : t('settings.openchamber.passkeys.actions.add')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -196,14 +198,14 @@ export const PasskeySettings: React.FC = () => {
|
||||
disabled={isLoading || isRegistering || isResetting}
|
||||
className="!font-normal text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{isResetting ? 'Signing out…' : 'Sign out everywhere'}
|
||||
{isResetting ? t('settings.openchamber.passkeys.actions.signingOut') : t('settings.openchamber.passkeys.actions.signOutEverywhere')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!status.enabled && (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Passkeys are available only when the UI password lock is enabled.
|
||||
{t('settings.openchamber.passkeys.state.uiPasswordRequired')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -214,9 +216,9 @@ export const PasskeySettings: React.FC = () => {
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="typography-meta text-muted-foreground">Loading passkeys…</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.openchamber.passkeys.state.loading')}</p>
|
||||
) : passkeys.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">No passkeys saved for this host yet.</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.openchamber.passkeys.state.noneSaved')}</p>
|
||||
) : (
|
||||
<div className="space-y-1 pt-1">
|
||||
{passkeys.map((passkey) => (
|
||||
@@ -226,7 +228,13 @@ export const PasskeySettings: React.FC = () => {
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||
<span className="typography-meta text-muted-foreground truncate">
|
||||
{passkey.lastUsedAt ? `Last used ${formatTimestamp(passkey.lastUsedAt)}` : `Added ${formatTimestamp(passkey.createdAt)}`}
|
||||
{passkey.lastUsedAt
|
||||
? t('settings.openchamber.passkeys.item.lastUsed', {
|
||||
time: formatTimestamp(passkey.lastUsedAt, t('settings.openchamber.passkeys.time.neverUsed')),
|
||||
})
|
||||
: t('settings.openchamber.passkeys.item.added', {
|
||||
time: formatTimestamp(passkey.createdAt, t('settings.openchamber.passkeys.time.neverUsed')),
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -236,7 +244,7 @@ export const PasskeySettings: React.FC = () => {
|
||||
disabled={revokingId === passkey.id}
|
||||
className="!font-normal text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{revokingId === passkey.id ? 'Removing…' : 'Remove'}
|
||||
{revokingId === passkey.id ? t('settings.openchamber.passkeys.actions.removing') : t('settings.common.actions.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,16 +7,18 @@ import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const MIN_DAYS = 1;
|
||||
const MAX_DAYS = 365;
|
||||
const DEFAULT_RETENTION_DAYS = 30;
|
||||
const RETENTION_ACTION_OPTIONS = [
|
||||
{ value: 'archive', label: 'Archive' },
|
||||
{ value: 'delete', label: 'Delete' },
|
||||
{ value: 'archive', labelKey: 'settings.openchamber.sessionRetention.action.archive' },
|
||||
{ value: 'delete', labelKey: 'settings.openchamber.sessionRetention.action.delete' },
|
||||
] as const;
|
||||
|
||||
export const SessionRetentionSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const autoDeleteEnabled = useUIStore((state) => state.autoDeleteEnabled);
|
||||
const autoDeleteAfterDays = useUIStore((state) => state.autoDeleteAfterDays);
|
||||
const sessionRetentionAction = useUIStore((state) => state.sessionRetentionAction);
|
||||
@@ -29,35 +31,44 @@ export const SessionRetentionSettings: React.FC = () => {
|
||||
|
||||
const handleRunCleanup = React.useCallback(async () => {
|
||||
const result = await runCleanup({ force: true });
|
||||
const verb = result.action === 'archive' ? 'archiving' : 'deletion';
|
||||
const pastTense = result.action === 'archive' ? 'Archived' : 'Deleted';
|
||||
const failureVerb = result.action === 'archive' ? 'archive' : 'delete';
|
||||
|
||||
if (result.completedIds.length === 0 && result.failedIds.length === 0) {
|
||||
toast.message(`No sessions eligible for ${verb}`);
|
||||
toast.message(
|
||||
result.action === 'archive'
|
||||
? t('settings.openchamber.sessionRetention.toast.noneEligibleArchive')
|
||||
: t('settings.openchamber.sessionRetention.toast.noneEligibleDelete')
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (result.completedIds.length > 0) {
|
||||
toast.success(`${pastTense} ${result.completedIds.length} session${result.completedIds.length === 1 ? '' : 's'}`);
|
||||
toast.success(
|
||||
result.action === 'archive'
|
||||
? t('settings.openchamber.sessionRetention.toast.archivedCount', { count: result.completedIds.length })
|
||||
: t('settings.openchamber.sessionRetention.toast.deletedCount', { count: result.completedIds.length })
|
||||
);
|
||||
}
|
||||
if (result.failedIds.length > 0) {
|
||||
toast.error(`Failed to ${failureVerb} ${result.failedIds.length} session${result.failedIds.length === 1 ? '' : 's'}`);
|
||||
toast.error(
|
||||
result.action === 'archive'
|
||||
? t('settings.openchamber.sessionRetention.toast.failedArchiveCount', { count: result.failedIds.length })
|
||||
: t('settings.openchamber.sessionRetention.toast.failedDeleteCount', { count: result.failedIds.length })
|
||||
);
|
||||
}
|
||||
}, [runCleanup]);
|
||||
}, [runCleanup, t]);
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Session Retention
|
||||
{t('settings.openchamber.sessionRetention.title')}
|
||||
</h3>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Automatically archive or delete inactive sessions based on last activity. Keeps the 5 most recent sessions.
|
||||
{t('settings.openchamber.sessionRetention.tooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -80,14 +91,14 @@ export const SessionRetentionSettings: React.FC = () => {
|
||||
<Checkbox
|
||||
checked={autoDeleteEnabled}
|
||||
onChange={setAutoDeleteEnabled}
|
||||
ariaLabel="Enable auto-cleanup"
|
||||
ariaLabel={t('settings.openchamber.sessionRetention.field.enableAutoCleanupAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Enable Auto-Cleanup</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.sessionRetention.field.enableAutoCleanup')}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">Retention Period</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.sessionRetention.field.retentionPeriod')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:w-fit">
|
||||
<NumberInput
|
||||
@@ -96,18 +107,18 @@ export const SessionRetentionSettings: React.FC = () => {
|
||||
min={MIN_DAYS}
|
||||
max={MAX_DAYS}
|
||||
step={1}
|
||||
aria-label="Retention period in days"
|
||||
aria-label={t('settings.openchamber.sessionRetention.field.retentionPeriodAria')}
|
||||
className="w-20 tabular-nums"
|
||||
/>
|
||||
<span className="typography-ui-label text-muted-foreground">days</span>
|
||||
<span className="typography-ui-label text-muted-foreground">{t('settings.openchamber.sessionRetention.field.days')}</span>
|
||||
<Button size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setAutoDeleteAfterDays(DEFAULT_RETENTION_DAYS)}
|
||||
disabled={autoDeleteAfterDays === DEFAULT_RETENTION_DAYS}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Reset retention period"
|
||||
title="Reset"
|
||||
aria-label={t('settings.openchamber.sessionRetention.actions.resetRetentionAria')}
|
||||
title={t('settings.common.actions.reset')}
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -116,7 +127,7 @@ export const SessionRetentionSettings: React.FC = () => {
|
||||
|
||||
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">When sessions expire</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.sessionRetention.field.whenSessionsExpire')}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1 sm:w-fit">
|
||||
{RETENTION_ACTION_OPTIONS.map((option) => (
|
||||
@@ -129,7 +140,7 @@ export const SessionRetentionSettings: React.FC = () => {
|
||||
className="!font-normal"
|
||||
onClick={() => setSessionRetentionAction(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
{t(option.labelKey)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
@@ -139,7 +150,7 @@ export const SessionRetentionSettings: React.FC = () => {
|
||||
<div className="mt-1 px-2 py-1.5 space-y-1">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<p className="typography-meta text-foreground font-medium">Manual Cleanup</p>
|
||||
<p className="typography-meta text-foreground font-medium">{t('settings.openchamber.sessionRetention.manualCleanup.title')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:w-fit">
|
||||
<Button
|
||||
@@ -150,12 +161,14 @@ export const SessionRetentionSettings: React.FC = () => {
|
||||
disabled={isRunning}
|
||||
className="!font-normal"
|
||||
>
|
||||
{isRunning ? 'Cleaning up...' : 'Run cleanup now'}
|
||||
{isRunning ? t('settings.openchamber.sessionRetention.actions.cleaningUp') : t('settings.openchamber.sessionRetention.actions.runCleanupNow')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Eligible for {action === 'archive' ? 'archiving' : 'deletion'} right now: <span className="tabular-nums">{pendingCount}</span>
|
||||
{action === 'archive'
|
||||
? t('settings.openchamber.sessionRetention.manualCleanup.eligibleArchiveNow', { count: pendingCount })
|
||||
: t('settings.openchamber.sessionRetention.manualCleanup.eligibleDeleteNow', { count: pendingCount })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { requestFileAccess } from '@/lib/desktop';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
|
||||
@@ -67,14 +68,26 @@ const SESSION_TTL_OPTIONS: TtlOption[] = [
|
||||
const MANAGED_REMOTE_TUNNEL_DOC_URL = 'https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/get-started/create-remote-tunnel/';
|
||||
const MANAGED_LOCAL_TUNNEL_DOC_URL = 'https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/local-management/configuration-file/';
|
||||
|
||||
const TUNNEL_MODE_OPTIONS: Array<{ value: TunnelMode; label: string; tooltip: string }> = [
|
||||
{ value: 'quick', label: 'Quick', tooltip: 'Quick Tunnel is best effort and Cloudflare does not guarantee uptime.' },
|
||||
{ value: 'managed-remote', label: 'Managed Remote', tooltip: 'Managed Remote uses your Cloudflare account and hostname for long-lived access.' },
|
||||
{ value: 'managed-local', label: 'Managed Local', tooltip: 'Managed Local uses your local cloudflared configuration file.' },
|
||||
const TUNNEL_MODE_OPTIONS: Array<{ value: TunnelMode; labelKey: string; tooltipKey: string }> = [
|
||||
{
|
||||
value: 'quick',
|
||||
labelKey: 'settings.openchamber.tunnel.option.mode.quick.label',
|
||||
tooltipKey: 'settings.openchamber.tunnel.option.mode.quick.tooltip',
|
||||
},
|
||||
{
|
||||
value: 'managed-remote',
|
||||
labelKey: 'settings.openchamber.tunnel.option.mode.managedRemote.label',
|
||||
tooltipKey: 'settings.openchamber.tunnel.option.mode.managedRemote.tooltip',
|
||||
},
|
||||
{
|
||||
value: 'managed-local',
|
||||
labelKey: 'settings.openchamber.tunnel.option.mode.managedLocal.label',
|
||||
tooltipKey: 'settings.openchamber.tunnel.option.mode.managedLocal.tooltip',
|
||||
},
|
||||
];
|
||||
|
||||
const MANAGED_LOCAL_CONFIG_ALLOWED_EXTENSIONS = ['.yml', '.yaml', '.json'];
|
||||
const MANAGED_LOCAL_CONFIG_EXTENSION_ERROR = 'Config file must use .yml, .yaml, or .json extension.';
|
||||
const MANAGED_LOCAL_CONFIG_EXTENSION_ERROR_KEY = 'settings.openchamber.tunnel.error.invalidConfigExtension';
|
||||
|
||||
const hasAllowedManagedLocalConfigExtension = (filePath: string): boolean => {
|
||||
const normalized = filePath.trim().toLowerCase();
|
||||
@@ -258,6 +271,8 @@ const createPresetId = (): string => {
|
||||
};
|
||||
|
||||
export const TunnelSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const tUnsafe = React.useCallback((key: string) => t(key as Parameters<typeof t>[0]), [t]);
|
||||
const [state, setState] = React.useState<TunnelState>('checking');
|
||||
const [tunnelInfo, setTunnelInfo] = React.useState<TunnelInfo | null>(null);
|
||||
const [activeTunnelMode, setActiveTunnelMode] = React.useState<TunnelMode | null>(null);
|
||||
@@ -286,6 +301,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
const [sessionRecords, setSessionRecords] = React.useState<TunnelSessionRecord[]>([]);
|
||||
const [nowTs, setNowTs] = React.useState<number>(() => Date.now());
|
||||
const [localPort, setLocalPort] = React.useState<number | null>(null);
|
||||
const managedLocalConfigExtensionError = t(MANAGED_LOCAL_CONFIG_EXTENSION_ERROR_KEY);
|
||||
const managedLocalConfigFileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const isManagedLocalConfigPathInvalid = React.useMemo(() => {
|
||||
if (!managedLocalConfigPath) {
|
||||
@@ -306,8 +322,10 @@ export const TunnelSettings: React.FC = () => {
|
||||
? formatRemaining(record.expiresAt - nowTs)
|
||||
: (record.inactiveReason === 'expired' || isExpired ? 'expired' : 'inactive');
|
||||
const inactiveLabel = remainingTextForSession === 'expired'
|
||||
? 'Expired'
|
||||
: (record.inactiveReason === 'tunnel-revoked' ? 'Revoked' : 'Inactive');
|
||||
? t('settings.openchamber.tunnel.state.expired')
|
||||
: (record.inactiveReason === 'tunnel-revoked'
|
||||
? t('settings.openchamber.tunnel.state.revoked')
|
||||
: t('settings.openchamber.tunnel.state.inactive'));
|
||||
|
||||
const mode = toUiTunnelMode(record.mode);
|
||||
return {
|
||||
@@ -318,7 +336,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
inactiveLabel,
|
||||
};
|
||||
});
|
||||
}, [nowTs, sessionRecords]);
|
||||
}, [nowTs, sessionRecords, t]);
|
||||
const isConnectLinkLive = React.useMemo(() => {
|
||||
if (!tunnelInfo?.connectUrl) {
|
||||
return false;
|
||||
@@ -444,10 +462,10 @@ export const TunnelSettings: React.FC = () => {
|
||||
} catch {
|
||||
if (!signal.aborted) {
|
||||
setState('error');
|
||||
setErrorMessage('Failed to check tunnel availability');
|
||||
setErrorMessage(t('settings.openchamber.tunnel.toast.checkAvailabilityFailed'));
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -483,7 +501,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!tunnelInfo?.bootstrapExpiresAt) {
|
||||
setRemainingText('No expiry');
|
||||
setRemainingText(t('settings.openchamber.tunnel.state.noExpiry'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -493,7 +511,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
const updateRemaining = () => {
|
||||
const remaining = tunnelInfo.bootstrapExpiresAt ? tunnelInfo.bootstrapExpiresAt - Date.now() : 0;
|
||||
if (remaining <= 0) {
|
||||
setRemainingText('Expired');
|
||||
setRemainingText(t('settings.openchamber.tunnel.state.expired'));
|
||||
} else {
|
||||
setRemainingText(formatRemaining(remaining));
|
||||
}
|
||||
@@ -533,7 +551,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
};
|
||||
}, [tunnelInfo?.bootstrapExpiresAt]);
|
||||
}, [t, tunnelInfo?.bootstrapExpiresAt]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Use requestAnimationFrame for smoother updates without setInterval overhead
|
||||
@@ -637,11 +655,11 @@ export const TunnelSettings: React.FC = () => {
|
||||
setManagedRemoteTunnelPresets(payload.managedRemoteTunnelPresets);
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to save tunnel settings');
|
||||
toast.error(t('settings.openchamber.tunnel.toast.saveSettingsFailed'));
|
||||
} finally {
|
||||
setIsSavingMode(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const saveTtlSettings = React.useCallback(async (nextBootstrapTtlMs: number | null, nextSessionTtlMs: number) => {
|
||||
setIsSavingTtl(true);
|
||||
@@ -651,11 +669,11 @@ export const TunnelSettings: React.FC = () => {
|
||||
tunnelSessionTtlMs: nextSessionTtlMs,
|
||||
});
|
||||
} catch {
|
||||
toast.error('Failed to save tunnel TTL settings');
|
||||
toast.error(t('settings.openchamber.tunnel.toast.saveTtlFailed'));
|
||||
} finally {
|
||||
setIsSavingTtl(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const persistManagedRemoteTunnelToken = React.useCallback(async (payload: {
|
||||
presetId: string;
|
||||
@@ -682,9 +700,9 @@ export const TunnelSettings: React.FC = () => {
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
toast.error('Failed to save managed remote tunnel token');
|
||||
toast.error(t('settings.openchamber.tunnel.toast.saveTokenFailed'));
|
||||
}
|
||||
}, [sessionTokensByPresetId]);
|
||||
}, [sessionTokensByPresetId, t]);
|
||||
|
||||
const handleProviderChange = React.useCallback(async (provider: string) => {
|
||||
setManagedRemoteValidationError(null);
|
||||
@@ -700,7 +718,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
if (result.success && typeof result.path === 'string' && result.path.trim().length > 0) {
|
||||
const nextPath = result.path.trim();
|
||||
if (!hasAllowedManagedLocalConfigExtension(nextPath)) {
|
||||
toast.error(MANAGED_LOCAL_CONFIG_EXTENSION_ERROR);
|
||||
toast.error(managedLocalConfigExtensionError);
|
||||
return;
|
||||
}
|
||||
setManagedLocalConfigPath(nextPath);
|
||||
@@ -709,7 +727,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
}
|
||||
|
||||
managedLocalConfigFileInputRef.current?.click();
|
||||
}, [saveTunnelSettings]);
|
||||
}, [managedLocalConfigExtensionError, saveTunnelSettings]);
|
||||
|
||||
const handleManagedLocalConfigInputChange = React.useCallback((value: string) => {
|
||||
const trimmed = value.trim();
|
||||
@@ -718,11 +736,11 @@ export const TunnelSettings: React.FC = () => {
|
||||
|
||||
const handleManagedLocalConfigInputBlur = React.useCallback(async () => {
|
||||
if (managedLocalConfigPath && !hasAllowedManagedLocalConfigExtension(managedLocalConfigPath)) {
|
||||
toast.error(MANAGED_LOCAL_CONFIG_EXTENSION_ERROR);
|
||||
toast.error(managedLocalConfigExtensionError);
|
||||
return;
|
||||
}
|
||||
await saveTunnelSettings({ managedLocalTunnelConfigPath: managedLocalConfigPath });
|
||||
}, [managedLocalConfigPath, saveTunnelSettings]);
|
||||
}, [managedLocalConfigExtensionError, managedLocalConfigPath, saveTunnelSettings]);
|
||||
|
||||
const handleManagedLocalConfigClear = React.useCallback(async () => {
|
||||
setManagedLocalConfigPath(null);
|
||||
@@ -740,22 +758,22 @@ export const TunnelSettings: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
if (!hasAllowedManagedLocalConfigExtension(fallbackPath)) {
|
||||
toast.error(MANAGED_LOCAL_CONFIG_EXTENSION_ERROR);
|
||||
toast.error(managedLocalConfigExtensionError);
|
||||
return;
|
||||
}
|
||||
|
||||
setManagedLocalConfigPath(fallbackPath);
|
||||
await saveTunnelSettings({ managedLocalTunnelConfigPath: fallbackPath });
|
||||
event.target.value = '';
|
||||
}, [saveTunnelSettings]);
|
||||
}, [managedLocalConfigExtensionError, saveTunnelSettings]);
|
||||
|
||||
const handleStart = React.useCallback(async () => {
|
||||
setErrorMessage(null);
|
||||
setManagedRemoteValidationError(null);
|
||||
|
||||
if (tunnelMode === 'managed-local' && managedLocalConfigPath && !hasAllowedManagedLocalConfigExtension(managedLocalConfigPath)) {
|
||||
setErrorMessage(MANAGED_LOCAL_CONFIG_EXTENSION_ERROR);
|
||||
toast.error(MANAGED_LOCAL_CONFIG_EXTENSION_ERROR);
|
||||
setErrorMessage(managedLocalConfigExtensionError);
|
||||
toast.error(managedLocalConfigExtensionError);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -768,8 +786,8 @@ export const TunnelSettings: React.FC = () => {
|
||||
if (tunnelMode === 'managed-remote') {
|
||||
if (!selectedPreset) {
|
||||
setState('idle');
|
||||
setManagedRemoteValidationError('Select or add a managed remote tunnel first');
|
||||
toast.error('Select or add a managed remote tunnel first');
|
||||
setManagedRemoteValidationError(t('settings.openchamber.tunnel.toast.selectOrAddManagedRemoteFirst'));
|
||||
toast.error(t('settings.openchamber.tunnel.toast.selectOrAddManagedRemoteFirst'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -802,21 +820,21 @@ export const TunnelSettings: React.FC = () => {
|
||||
if (!res.ok || !data.ok) {
|
||||
if (tunnelMode === 'managed-remote' && typeof data.error === 'string' && data.error.includes('Managed remote tunnel token is required')) {
|
||||
setState('idle');
|
||||
setManagedRemoteValidationError('Managed remote tunnel token is required before starting');
|
||||
toast.error('Add a managed remote tunnel token before starting');
|
||||
setManagedRemoteValidationError(t('settings.openchamber.tunnel.toast.managedRemoteTokenRequiredBeforeStarting'));
|
||||
toast.error(t('settings.openchamber.tunnel.toast.addManagedRemoteTokenBeforeStarting'));
|
||||
return;
|
||||
}
|
||||
setState('error');
|
||||
setErrorMessage(data.error || 'Failed to start tunnel');
|
||||
toast.error(data.error || 'Failed to start tunnel');
|
||||
setErrorMessage(data.error || t('settings.openchamber.tunnel.toast.startFailed'));
|
||||
toast.error(data.error || t('settings.openchamber.tunnel.toast.startFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
const startedUrl = typeof data.url === 'string' ? data.url : '';
|
||||
if (!startedUrl) {
|
||||
setState('error');
|
||||
setErrorMessage('Tunnel started but no public URL was returned');
|
||||
toast.error('Tunnel started but no public URL was returned');
|
||||
setErrorMessage(t('settings.openchamber.tunnel.toast.startedButNoPublicUrl'));
|
||||
toast.error(t('settings.openchamber.tunnel.toast.startedButNoPublicUrl'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -844,20 +862,30 @@ export const TunnelSettings: React.FC = () => {
|
||||
if (data.replacedTunnel) {
|
||||
const revokedBootstrapCount = typeof data.revokedBootstrapCount === 'number' ? data.revokedBootstrapCount : 0;
|
||||
const invalidatedSessionCount = typeof data.invalidatedSessionCount === 'number' ? data.invalidatedSessionCount : 0;
|
||||
toast.warning(`Replaced previous tunnel: revoked ${revokedBootstrapCount} link${revokedBootstrapCount === 1 ? '' : 's'}, invalidated ${invalidatedSessionCount} session${invalidatedSessionCount === 1 ? '' : 's'}.`);
|
||||
if (revokedBootstrapCount === 1 && invalidatedSessionCount === 1) {
|
||||
toast.warning(t('settings.openchamber.tunnel.toast.replacedTunnelSingleSingle'));
|
||||
} else if (revokedBootstrapCount === 1) {
|
||||
toast.warning(t('settings.openchamber.tunnel.toast.replacedTunnelSingleManySessions', { invalidatedSessionCount }));
|
||||
} else if (invalidatedSessionCount === 1) {
|
||||
toast.warning(t('settings.openchamber.tunnel.toast.replacedTunnelManyLinksSingleSession', { revokedBootstrapCount }));
|
||||
} else {
|
||||
toast.warning(t('settings.openchamber.tunnel.toast.replacedTunnelManyMany', { revokedBootstrapCount, invalidatedSessionCount }));
|
||||
}
|
||||
} else {
|
||||
toast.success('Tunnel link ready');
|
||||
toast.success(t('settings.openchamber.tunnel.toast.linkReady'));
|
||||
}
|
||||
} catch {
|
||||
setState('error');
|
||||
setErrorMessage('Failed to start tunnel');
|
||||
toast.error('Failed to start tunnel');
|
||||
setErrorMessage(t('settings.openchamber.tunnel.toast.startFailed'));
|
||||
toast.error(t('settings.openchamber.tunnel.toast.startFailed'));
|
||||
}
|
||||
}, [
|
||||
managedLocalConfigExtensionError,
|
||||
managedRemoteTunnelPresets,
|
||||
saveTunnelSettings,
|
||||
selectedPreset,
|
||||
sessionTokensByPresetId,
|
||||
t,
|
||||
tunnelProvider,
|
||||
tunnelMode,
|
||||
managedLocalConfigPath,
|
||||
@@ -879,13 +907,13 @@ export const TunnelSettings: React.FC = () => {
|
||||
setActiveTunnelMode(null);
|
||||
setQrDataUrl(null);
|
||||
setState('idle');
|
||||
toast.success('Tunnel stopped');
|
||||
toast.success(t('settings.openchamber.tunnel.toast.stopped'));
|
||||
} catch {
|
||||
setState('error');
|
||||
setErrorMessage('Failed to stop tunnel');
|
||||
toast.error('Failed to stop tunnel');
|
||||
setErrorMessage(t('settings.openchamber.tunnel.toast.stopFailed'));
|
||||
toast.error(t('settings.openchamber.tunnel.toast.stopFailed'));
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleCopyUrl = React.useCallback(async () => {
|
||||
if (!tunnelInfo?.connectUrl) {
|
||||
@@ -895,12 +923,12 @@ export const TunnelSettings: React.FC = () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(tunnelInfo.connectUrl);
|
||||
setCopied(true);
|
||||
toast.success('Connect link copied');
|
||||
toast.success(t('settings.openchamber.tunnel.toast.connectLinkCopied'));
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error('Failed to copy URL');
|
||||
toast.error(t('settings.openchamber.tunnel.toast.copyUrlFailed'));
|
||||
}
|
||||
}, [tunnelInfo?.connectUrl]);
|
||||
}, [t, tunnelInfo?.connectUrl]);
|
||||
|
||||
const handleBootstrapTtlChange = React.useCallback(async (value: string) => {
|
||||
const option = BOOTSTRAP_TTL_OPTIONS.find((entry) => entry.value === value);
|
||||
@@ -939,9 +967,9 @@ export const TunnelSettings: React.FC = () => {
|
||||
managedRemoteTunnelPresets: presets,
|
||||
});
|
||||
} catch {
|
||||
toast.error('Failed to save selected managed remote tunnel');
|
||||
toast.error(t('settings.openchamber.tunnel.toast.saveSelectedManagedRemoteFailed'));
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleSelectPreset = React.useCallback((presetId: string) => {
|
||||
const preset = managedRemoteTunnelPresets.find((entry) => entry.id === presetId);
|
||||
@@ -960,20 +988,20 @@ export const TunnelSettings: React.FC = () => {
|
||||
const token = newPresetToken.trim();
|
||||
|
||||
if (!name) {
|
||||
toast.error('Tunnel name is required');
|
||||
toast.error(t('settings.openchamber.tunnel.toast.tunnelNameRequired'));
|
||||
return;
|
||||
}
|
||||
if (!hostname) {
|
||||
toast.error('Managed remote tunnel hostname is required');
|
||||
toast.error(t('settings.openchamber.tunnel.toast.managedRemoteHostnameRequired'));
|
||||
return;
|
||||
}
|
||||
if (!token) {
|
||||
toast.error('Managed remote tunnel token is required');
|
||||
toast.error(t('settings.openchamber.tunnel.toast.managedRemoteTokenRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (managedRemoteTunnelPresets.some((preset) => preset.hostname === hostname)) {
|
||||
toast.error('This hostname already exists');
|
||||
toast.error(t('settings.openchamber.tunnel.toast.hostnameAlreadyExists'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1008,8 +1036,8 @@ export const TunnelSettings: React.FC = () => {
|
||||
hostname: nextPreset.hostname,
|
||||
token,
|
||||
});
|
||||
toast.success('Managed remote tunnel saved');
|
||||
}, [managedRemoteTunnelPresets, newPresetHostname, newPresetName, newPresetToken, persistManagedRemoteTunnelToken, saveTunnelSettings, sessionTokensByPresetId]);
|
||||
toast.success(t('settings.openchamber.tunnel.toast.managedRemoteSaved'));
|
||||
}, [managedRemoteTunnelPresets, newPresetHostname, newPresetName, newPresetToken, persistManagedRemoteTunnelToken, saveTunnelSettings, sessionTokensByPresetId, t]);
|
||||
|
||||
const handleRemovePreset = React.useCallback(async (presetId: string) => {
|
||||
const preset = managedRemoteTunnelPresets.find((entry) => entry.id === presetId);
|
||||
@@ -1049,15 +1077,15 @@ export const TunnelSettings: React.FC = () => {
|
||||
managedRemoteTunnelPresetTokens: nextTokenMap,
|
||||
});
|
||||
|
||||
toast.success('Managed remote tunnel removed');
|
||||
}, [managedRemoteTunnelPresets, saveTunnelSettings, selectedPresetId, sessionTokensByPresetId]);
|
||||
toast.success(t('settings.openchamber.tunnel.toast.managedRemoteRemoved'));
|
||||
}, [managedRemoteTunnelPresets, saveTunnelSettings, selectedPresetId, sessionTokensByPresetId, t]);
|
||||
|
||||
const primaryCtaClass = 'gap-2 border-[var(--primary-base)] bg-[var(--primary-base)] text-[var(--primary-foreground)] hover:bg-[var(--primary-hover)] hover:text-[var(--primary-foreground)]';
|
||||
|
||||
if (state === 'checking') {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-current animate-busy-pulse" aria-label="Loading" />
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-current animate-busy-pulse" aria-label={t('settings.openchamber.tunnel.state.loading')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1065,15 +1093,15 @@ export const TunnelSettings: React.FC = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">Remote Tunnel</h3>
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">{t('settings.openchamber.tunnel.title')}</h3>
|
||||
<p className="typography-meta mt-0 text-muted-foreground/70">
|
||||
Configure secure remote access with quick links or your own managed remote Cloudflare tunnel.
|
||||
{t('settings.openchamber.tunnel.description')}
|
||||
</p>
|
||||
<p className="typography-meta mt-0 text-muted-foreground/60">
|
||||
Secure Tunnel access is enforced server-side.
|
||||
{t('settings.openchamber.tunnel.note.serverSideEnforced')}
|
||||
</p>
|
||||
<p className="typography-meta mt-0 text-muted-foreground/60">
|
||||
Connect links are one-time and are revoked when tunnel stops or Connect link TTL expired.
|
||||
{t('settings.openchamber.tunnel.note.connectLinksOneTime')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1082,7 +1110,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
<div className="rounded-lg border border-[var(--status-info-border)] bg-[var(--status-info-background)]/30 p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<RiInformationLine className="size-4 text-[var(--status-info)]" />
|
||||
<p className="typography-ui-label text-foreground">Redeemed access links</p>
|
||||
<p className="typography-ui-label text-foreground">{t('settings.openchamber.tunnel.section.redeemedAccessLinks')}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{renderedSessionRecords.map((record) => {
|
||||
@@ -1096,7 +1124,11 @@ export const TunnelSettings: React.FC = () => {
|
||||
const statusDotClass = record.isActive
|
||||
? (isQuick ? 'text-[var(--status-warning)]' : isManagedRemote ? 'text-[var(--status-info)]' : 'text-[var(--status-success)]')
|
||||
: 'text-muted-foreground/50';
|
||||
const modeLabel = isQuick ? 'QUICK' : isManagedRemote ? 'REMOTE' : 'LOCAL';
|
||||
const modeLabel = isQuick
|
||||
? t('settings.openchamber.tunnel.badge.quick')
|
||||
: isManagedRemote
|
||||
? t('settings.openchamber.tunnel.badge.remote')
|
||||
: t('settings.openchamber.tunnel.badge.local');
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1108,12 +1140,14 @@ export const TunnelSettings: React.FC = () => {
|
||||
{modeLabel}
|
||||
</span>
|
||||
<span className="typography-meta text-muted-foreground/80">
|
||||
Redeemed {formatAbsoluteTime(record.createdAt)}
|
||||
{t('settings.openchamber.tunnel.session.redeemedAt', { time: formatAbsoluteTime(record.createdAt) })}
|
||||
</span>
|
||||
<span className="typography-meta text-foreground">
|
||||
{record.isActive
|
||||
? `Expires in ${record.remainingTextForSession}`
|
||||
: (record.inactiveLabel === 'Inactive' ? 'Inactive' : `Inactive (${record.inactiveLabel})`)}
|
||||
? t('settings.openchamber.tunnel.session.expiresIn', { remaining: record.remainingTextForSession })
|
||||
: (record.inactiveLabel === t('settings.openchamber.tunnel.state.inactive')
|
||||
? t('settings.openchamber.tunnel.state.inactive')
|
||||
: t('settings.openchamber.tunnel.session.inactiveWithReason', { reason: record.inactiveLabel }))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -1128,8 +1162,8 @@ export const TunnelSettings: React.FC = () => {
|
||||
<div className="flex items-start gap-2 rounded-lg border border-[var(--status-warning)]/30 bg-[var(--status-warning)]/5 p-3">
|
||||
<RiErrorWarningLine className="mt-0.5 size-4 shrink-0 text-[var(--status-warning)]" />
|
||||
<div className="space-y-1">
|
||||
<p className="typography-meta font-medium text-foreground">cloudflared not found</p>
|
||||
<p className="typography-meta text-muted-foreground/70">Install it to enable remote tunnel access:</p>
|
||||
<p className="typography-meta font-medium text-foreground">{t('settings.openchamber.tunnel.notAvailable.cloudflaredNotFound')}</p>
|
||||
<p className="typography-meta text-muted-foreground/70">{t('settings.openchamber.tunnel.notAvailable.installHint')}</p>
|
||||
<code className="typography-code block rounded bg-muted/50 px-2 py-1 text-xs text-foreground">
|
||||
brew install cloudflared
|
||||
</code>
|
||||
@@ -1142,7 +1176,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
<section className="space-y-4 px-2 pb-2 pt-0">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<p className="typography-ui-label text-foreground">Provider</p>
|
||||
<p className="typography-ui-label text-foreground">{t('settings.openchamber.tunnel.field.provider')}</p>
|
||||
<Select
|
||||
value={tunnelProvider}
|
||||
onValueChange={(value) => {
|
||||
@@ -1151,7 +1185,9 @@ export const TunnelSettings: React.FC = () => {
|
||||
disabled={isSavingMode || state === 'starting' || state === 'stopping'}
|
||||
>
|
||||
<SelectTrigger className="max-w-[16rem]">
|
||||
<SelectValue placeholder="Select provider" />
|
||||
<SelectValue placeholder={t('settings.openchamber.tunnel.field.providerPlaceholder')}>
|
||||
{getProviderLabel(tunnelProvider)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerCapabilities.length > 0
|
||||
@@ -1165,13 +1201,13 @@ export const TunnelSettings: React.FC = () => {
|
||||
<ProviderOptionLabel provider="cloudflare" />
|
||||
</SelectItem>
|
||||
)}
|
||||
<SelectItem value="__more-soon" disabled>More providers coming soon</SelectItem>
|
||||
<SelectItem value="__more-soon" disabled>{t('settings.openchamber.tunnel.option.moreProvidersSoon')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="typography-ui-label text-foreground">Tunnel type</p>
|
||||
<p className="typography-ui-label text-foreground">{t('settings.openchamber.tunnel.field.tunnelType')}</p>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{TUNNEL_MODE_OPTIONS.map((option) => (
|
||||
<Tooltip key={option.value} delayDuration={700}>
|
||||
@@ -1186,11 +1222,11 @@ export const TunnelSettings: React.FC = () => {
|
||||
}}
|
||||
disabled={isSavingMode || state === 'starting' || state === 'stopping'}
|
||||
>
|
||||
{option.label}
|
||||
{tUnsafe(option.labelKey)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
{option.tooltip}
|
||||
{tUnsafe(option.tooltipKey)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
@@ -1200,7 +1236,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
|
||||
<div className="mt-2 grid grid-cols-1 gap-2 py-1.5 md:grid-cols-[14rem_auto] md:gap-x-8 md:gap-y-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="typography-ui-label shrink-0 text-foreground">Connect link TTL</span>
|
||||
<span className="typography-ui-label shrink-0 text-foreground">{t('settings.openchamber.tunnel.field.connectLinkTtl')}</span>
|
||||
<Select
|
||||
value={ttlOptionValue(BOOTSTRAP_TTL_OPTIONS, bootstrapTtlMs, '1800000')}
|
||||
onValueChange={(value) => {
|
||||
@@ -1220,7 +1256,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="typography-ui-label shrink-0 text-foreground">Tunnel session TTL</span>
|
||||
<span className="typography-ui-label shrink-0 text-foreground">{t('settings.openchamber.tunnel.field.tunnelSessionTtl')}</span>
|
||||
<Select
|
||||
value={ttlOptionValue(SESSION_TTL_OPTIONS, sessionTtlMs, '28800000')}
|
||||
onValueChange={(value) => {
|
||||
@@ -1246,10 +1282,10 @@ export const TunnelSettings: React.FC = () => {
|
||||
<RiErrorWarningLine className="mt-0.5 size-4 shrink-0 text-[var(--status-warning)]" />
|
||||
<div>
|
||||
<p className="typography-meta text-[var(--status-warning)]">
|
||||
Quick Tunnel is best effort and Cloudflare does not guarantee uptime.
|
||||
{t('settings.openchamber.tunnel.option.mode.quick.tooltip')}
|
||||
</p>
|
||||
<p className="typography-meta mt-1 text-[var(--status-warning)]">
|
||||
For more reliable long-lived access, switch to Managed Remote or Managed Local tunnel mode.
|
||||
{t('settings.openchamber.tunnel.warning.quickModeReliability')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1261,13 +1297,13 @@ export const TunnelSettings: React.FC = () => {
|
||||
{typeof suggestedConnectorPort === 'number' && (
|
||||
<div className="rounded-md border border-[var(--status-info-border)] bg-[var(--status-info-background)]/35 px-2 py-1.5">
|
||||
<p className="typography-meta text-[var(--status-info)]">
|
||||
Cloudflare connector target: <code>http://localhost:{suggestedConnectorPort}</code>
|
||||
{t('settings.openchamber.tunnel.note.cloudflareConnectorTarget')} <code>http://localhost:{suggestedConnectorPort}</code>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-1 flex items-center justify-between gap-3">
|
||||
<p className="typography-ui-label text-foreground">Saved managed remote tunnels</p>
|
||||
<p className="typography-ui-label text-foreground">{t('settings.openchamber.tunnel.section.savedManagedRemoteTunnels')}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
@@ -1276,7 +1312,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
disabled={state === 'starting' || state === 'stopping' || isSavingMode}
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
Add
|
||||
{t('settings.common.actions.create')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1318,7 +1354,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-[var(--status-error)]"
|
||||
aria-label={`Remove ${preset.name}`}
|
||||
aria-label={t('settings.openchamber.tunnel.actions.removePresetAria', { name: preset.name })}
|
||||
onClick={() => {
|
||||
void handleRemovePreset(preset.id);
|
||||
}}
|
||||
@@ -1330,7 +1366,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
|
||||
<CollapsibleContent className="pt-1.5">
|
||||
<div className="space-y-1 px-3 pb-2">
|
||||
<p className="typography-meta text-muted-foreground/70">Hostname: <code>{preset.hostname}</code></p>
|
||||
<p className="typography-meta text-muted-foreground/70">{t('settings.openchamber.tunnel.field.hostnameLabel')} <code>{preset.hostname}</code></p>
|
||||
<Input
|
||||
type="password"
|
||||
value={rowToken}
|
||||
@@ -1351,7 +1387,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
token: tokenToSave,
|
||||
});
|
||||
}}
|
||||
placeholder={hasSavedToken ? 'Saved token available (optional to replace)' : 'Paste token for this tunnel'}
|
||||
placeholder={hasSavedToken ? t('settings.openchamber.tunnel.field.savedTokenAvailablePlaceholder') : t('settings.openchamber.tunnel.field.pasteTokenPlaceholder')}
|
||||
className="h-7"
|
||||
disabled={state === 'starting' || state === 'stopping'}
|
||||
/>
|
||||
@@ -1370,7 +1406,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
Save token
|
||||
{t('settings.openchamber.tunnel.actions.saveToken')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1381,7 +1417,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="typography-meta text-muted-foreground/70">No managed remote tunnels saved yet.</p>
|
||||
<p className="typography-meta text-muted-foreground/70">{t('settings.openchamber.tunnel.empty.noManagedRemoteTunnels')}</p>
|
||||
)}
|
||||
|
||||
{isAddingPreset && (
|
||||
@@ -1389,14 +1425,14 @@ export const TunnelSettings: React.FC = () => {
|
||||
<Input
|
||||
value={newPresetName}
|
||||
onChange={(event) => setNewPresetName(event.target.value)}
|
||||
placeholder="Tunnel name (e.g. Production)"
|
||||
placeholder={t('settings.openchamber.tunnel.field.newPresetNamePlaceholder')}
|
||||
className="h-7"
|
||||
disabled={isSavingMode || state === 'starting' || state === 'stopping'}
|
||||
/>
|
||||
<Input
|
||||
value={newPresetHostname}
|
||||
onChange={(event) => setNewPresetHostname(event.target.value)}
|
||||
placeholder="Hostname (e.g. oc.example.com)"
|
||||
placeholder={t('settings.openchamber.tunnel.field.newPresetHostnamePlaceholder')}
|
||||
className="h-7"
|
||||
disabled={isSavingMode || state === 'starting' || state === 'stopping'}
|
||||
/>
|
||||
@@ -1404,13 +1440,13 @@ export const TunnelSettings: React.FC = () => {
|
||||
type="password"
|
||||
value={newPresetToken}
|
||||
onChange={(event) => setNewPresetToken(event.target.value)}
|
||||
placeholder="Token"
|
||||
placeholder={t('settings.openchamber.tunnel.field.newPresetTokenPlaceholder')}
|
||||
className="h-7"
|
||||
disabled={isSavingMode || state === 'starting' || state === 'stopping'}
|
||||
/>
|
||||
{typeof suggestedConnectorPort === 'number' && (
|
||||
<p className="typography-meta text-muted-foreground/70">
|
||||
For Cloudflare connector target, use <code>http://localhost:{suggestedConnectorPort}</code>.
|
||||
{t('settings.openchamber.tunnel.note.cloudflareConnectorTargetUse')} <code>http://localhost:{suggestedConnectorPort}</code>.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1423,7 +1459,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
}}
|
||||
disabled={isSavingMode || state === 'starting' || state === 'stopping'}
|
||||
>
|
||||
Save
|
||||
{t('settings.common.actions.saveChanges')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1437,26 +1473,26 @@ export const TunnelSettings: React.FC = () => {
|
||||
}}
|
||||
disabled={isSavingMode || state === 'starting' || state === 'stopping'}
|
||||
>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="typography-meta text-muted-foreground/80">Tokens are saved per tunnel and reused from disk</p>
|
||||
<p className="typography-meta text-muted-foreground/80">{t('settings.openchamber.tunnel.note.tokensSavedPerTunnel')}</p>
|
||||
<Tooltip delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-0.5 text-muted-foreground/70 hover:text-foreground"
|
||||
aria-label="Managed remote tunnel token info"
|
||||
aria-label={t('settings.openchamber.tunnel.field.managedRemoteTokenInfoAria')}
|
||||
>
|
||||
<RiInformationLine className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Tokens are saved in ~/.config/openchamber/cloudflare-managed-remote-tunnels.json.
|
||||
{t('settings.openchamber.tunnel.tooltip.tokensSavedPath')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -1470,7 +1506,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
{tunnelMode === 'managed-local' && (
|
||||
<div className="space-y-2 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-3">
|
||||
<div className="space-y-1.5">
|
||||
<p className="typography-ui-label text-foreground">Configuration file</p>
|
||||
<p className="typography-ui-label text-foreground">{t('settings.openchamber.tunnel.field.configurationFile')}</p>
|
||||
<input
|
||||
ref={managedLocalConfigFileInputRef}
|
||||
type="file"
|
||||
@@ -1489,7 +1525,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
onBlur={() => {
|
||||
void handleManagedLocalConfigInputBlur();
|
||||
}}
|
||||
placeholder="Using default cloudflared config"
|
||||
placeholder={t('settings.openchamber.tunnel.field.configurationFilePlaceholder')}
|
||||
className="h-7"
|
||||
disabled={state === 'starting' || state === 'stopping' || isSavingMode}
|
||||
/>
|
||||
@@ -1497,7 +1533,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="h-7 w-7 p-0"
|
||||
aria-label="Browse config file"
|
||||
aria-label={t('settings.openchamber.tunnel.actions.browseConfigFileAria')}
|
||||
onClick={() => {
|
||||
void handleBrowseManagedLocalConfig();
|
||||
}}
|
||||
@@ -1510,7 +1546,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-7 w-7 p-0"
|
||||
aria-label="Clear config file"
|
||||
aria-label={t('settings.openchamber.tunnel.actions.clearConfigFileAria')}
|
||||
onClick={() => {
|
||||
void handleManagedLocalConfigClear();
|
||||
}}
|
||||
@@ -1522,11 +1558,11 @@ export const TunnelSettings: React.FC = () => {
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground/70">
|
||||
{managedLocalConfigPath
|
||||
? 'Custom config file will be used when starting the tunnel.'
|
||||
: 'When empty, cloudflared uses its default config (~/.cloudflared/config.yml).'}
|
||||
? t('settings.openchamber.tunnel.note.customConfigUsed')
|
||||
: t('settings.openchamber.tunnel.note.defaultConfigUsed')}
|
||||
</p>
|
||||
{isManagedLocalConfigPathInvalid && (
|
||||
<p className="typography-meta text-[var(--status-error)]">{MANAGED_LOCAL_CONFIG_EXTENSION_ERROR}</p>
|
||||
<p className="typography-meta text-[var(--status-error)]">{managedLocalConfigExtensionError}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1541,7 +1577,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
{tunnelMode === 'managed-remote' && (
|
||||
<>
|
||||
<p className="typography-meta text-[var(--status-info)]">
|
||||
Managed remote tunnels require a bought domain in your Cloudflare account.
|
||||
{t('settings.openchamber.tunnel.note.managedRemoteRequiresDomain')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1550,7 +1586,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
void openExternal(MANAGED_REMOTE_TUNNEL_DOC_URL);
|
||||
}}
|
||||
>
|
||||
Check the documentation on how to configure a managed remote tunnel
|
||||
{t('settings.openchamber.tunnel.actions.openManagedRemoteDocs')}
|
||||
<RiExternalLinkLine className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
@@ -1558,7 +1594,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
{tunnelMode === 'managed-local' && (
|
||||
<>
|
||||
<p className="typography-meta text-[var(--status-info)]">
|
||||
Managed local tunnels use your local cloudflared configuration file.
|
||||
{t('settings.openchamber.tunnel.note.managedLocalUsesConfig')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1567,13 +1603,15 @@ export const TunnelSettings: React.FC = () => {
|
||||
void openExternal(MANAGED_LOCAL_TUNNEL_DOC_URL);
|
||||
}}
|
||||
>
|
||||
Check the documentation on managed local tunnel configuration
|
||||
{t('settings.openchamber.tunnel.actions.openManagedLocalDocs')}
|
||||
<RiExternalLinkLine className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<p className="typography-meta text-[var(--status-info)]">
|
||||
Start a {tunnelMode} tunnel and generate a one-time connect link. Do not close the app while this tunnel is in use.
|
||||
{t('settings.openchamber.tunnel.note.startModeAndGenerateLink', {
|
||||
mode: tUnsafe(TUNNEL_MODE_OPTIONS.find((option) => option.value === tunnelMode)?.labelKey ?? 'settings.openchamber.tunnel.option.mode.quick.label'),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1581,7 +1619,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
|
||||
{tunnelMode === 'managed-remote' && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="typography-ui-label text-foreground">Managed remote tunnel to connect</p>
|
||||
<p className="typography-ui-label text-foreground">{t('settings.openchamber.tunnel.field.managedRemoteTunnelToConnect')}</p>
|
||||
<Select
|
||||
value={selectedPresetId || (managedRemoteTunnelPresets[0]?.id ?? '')}
|
||||
onValueChange={(presetId) => {
|
||||
@@ -1595,7 +1633,9 @@ export const TunnelSettings: React.FC = () => {
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select saved tunnel" />
|
||||
<SelectValue placeholder={t('settings.openchamber.tunnel.field.selectSavedTunnelPlaceholder')}>
|
||||
{selectedPreset?.name}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent fitContent>
|
||||
{managedRemoteTunnelPresets.map((preset) => (
|
||||
@@ -1611,7 +1651,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
<div className="flex items-start gap-2">
|
||||
<RiErrorWarningLine className="mt-0.5 size-4 shrink-0 text-[var(--status-warning)]" />
|
||||
<p className="typography-meta text-[var(--status-warning)]">
|
||||
Starting this tunnel replaces the active tunnel and revokes existing connect links and remote sessions.
|
||||
{t('settings.openchamber.tunnel.warning.replacesActiveTunnel')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1629,8 +1669,8 @@ export const TunnelSettings: React.FC = () => {
|
||||
className={cn(primaryCtaClass, state === 'starting' && 'opacity-70')}
|
||||
>
|
||||
{state === 'starting'
|
||||
? <><RiLoader4Line className="size-3.5 animate-spin" /> Starting tunnel...</>
|
||||
: 'Start Tunnel'}
|
||||
? <><RiLoader4Line className="size-3.5 animate-spin" /> {t('settings.openchamber.tunnel.actions.startingTunnel')}</>
|
||||
: t('settings.openchamber.tunnel.actions.startTunnel')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1643,11 +1683,11 @@ export const TunnelSettings: React.FC = () => {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-2 shrink-0 rounded-full bg-[var(--status-success)]" />
|
||||
<p className="typography-meta font-medium text-foreground">Tunnel ready</p>
|
||||
<p className="typography-meta font-medium text-foreground">{t('settings.openchamber.tunnel.state.tunnelReady')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="typography-meta mb-1 text-muted-foreground/70">Public URL (Not accessible without a token)</p>
|
||||
<p className="typography-meta mb-1 text-muted-foreground/70">{t('settings.openchamber.tunnel.field.publicUrlHint')}</p>
|
||||
<code className="typography-code block truncate rounded bg-muted/50 px-2 py-1 text-xs text-foreground">
|
||||
{tunnelInfo.url}
|
||||
</code>
|
||||
@@ -1656,7 +1696,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
{isConnectLinkLive && tunnelInfo.connectUrl && (
|
||||
<>
|
||||
<div>
|
||||
<p className="typography-meta mb-1 text-muted-foreground/70">Connect link</p>
|
||||
<p className="typography-meta mb-1 text-muted-foreground/70">{t('settings.openchamber.tunnel.field.connectLink')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="typography-code flex-1 truncate rounded bg-muted/50 px-2 py-1 text-xs text-foreground">
|
||||
{tunnelInfo.connectUrl}
|
||||
@@ -1665,19 +1705,19 @@ export const TunnelSettings: React.FC = () => {
|
||||
{copied
|
||||
? <RiCheckLine className="size-3.5 text-[var(--status-success)]" />
|
||||
: <RiFileCopyLine className="size-3.5" />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
{copied ? t('settings.openchamber.tunnel.actions.copied') : t('settings.common.actions.copyAll')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="typography-meta mt-1 text-muted-foreground/70">
|
||||
Expires: {tunnelInfo.bootstrapExpiresAt ? remainingText : 'Never'}
|
||||
{t('settings.openchamber.tunnel.field.expires')}: {tunnelInfo.bootstrapExpiresAt ? remainingText : t('settings.openchamber.tunnel.state.never')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2 rounded-lg border border-border/50 bg-[var(--surface-elevated)] p-4">
|
||||
{qrDataUrl
|
||||
? <img src={qrDataUrl} alt="Tunnel connect QR code" className="size-48" />
|
||||
? <img src={qrDataUrl} alt={t('settings.openchamber.tunnel.field.connectQrAlt')} className="size-48" />
|
||||
: <div className="size-48 rounded bg-muted/30" />}
|
||||
<p className="typography-meta text-muted-foreground">Scan with your phone to connect</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.openchamber.tunnel.note.scanQrToConnect')}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -1692,7 +1732,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
className={primaryCtaClass}
|
||||
>
|
||||
<RiRestartLine className="size-3.5" />
|
||||
New connect link
|
||||
{t('settings.openchamber.tunnel.actions.newConnectLink')}
|
||||
</Button>
|
||||
|
||||
<Button size="sm"
|
||||
@@ -1702,8 +1742,8 @@ export const TunnelSettings: React.FC = () => {
|
||||
className="gap-2 text-[var(--status-error)]"
|
||||
>
|
||||
{state === 'stopping'
|
||||
? <><RiLoader4Line className="size-3.5 animate-spin" /> Stopping...</>
|
||||
: 'Stop Tunnel'}
|
||||
? <><RiLoader4Line className="size-3.5 animate-spin" /> {t('settings.openchamber.tunnel.actions.stopping')}</>
|
||||
: t('settings.openchamber.tunnel.actions.stopTunnel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1713,7 +1753,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
{state === 'error' && errorMessage && (
|
||||
<section className="space-y-3 px-2 pb-2 pt-0">
|
||||
<p className="typography-meta text-[var(--status-error)]">{errorMessage}</p>
|
||||
<Button size="sm" variant="ghost" onClick={handleStart}>Retry</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleStart}>{t('settings.openchamber.tunnel.actions.retry')}</Button>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { browserVoiceService } from '@/lib/voice/browserVoiceService';
|
||||
import { audioStreamService } from '@/lib/voice/audioStreamService';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
const LANGUAGE_OPTIONS = [
|
||||
{ value: 'en-US', label: 'English' },
|
||||
{ value: 'es-ES', label: 'Español' },
|
||||
@@ -48,6 +49,7 @@ const OPENAI_VOICE_OPTIONS = [
|
||||
];
|
||||
|
||||
export const VoiceSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const {
|
||||
isSupported,
|
||||
@@ -159,8 +161,8 @@ export const VoiceSettings: React.FC = () => {
|
||||
}
|
||||
|
||||
const selectedVoice = browserVoices.find(v => v.name === browserVoice);
|
||||
const voiceName = selectedVoice?.name ?? 'your browser voice';
|
||||
const previewText = `Hello! I'm ${voiceName}. This is how I sound.`;
|
||||
const voiceName = selectedVoice?.name ?? t('settings.voice.page.preview.browserVoiceFallback');
|
||||
const previewText = t('settings.voice.page.preview.voiceLine', { voiceName });
|
||||
|
||||
setIsBrowserPreviewPlaying(true);
|
||||
|
||||
@@ -250,7 +252,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: `Hello! I'm ${sayVoice}. This is how I sound.`,
|
||||
text: t('settings.voice.page.preview.voiceLine', { voiceName: sayVoice }),
|
||||
voice: sayVoice,
|
||||
rate: Math.round(100 + (speechRate - 0.5) * 200),
|
||||
}),
|
||||
@@ -279,7 +281,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
} catch {
|
||||
setIsPreviewPlaying(false);
|
||||
}
|
||||
}, [sayVoice, speechRate, previewAudio]);
|
||||
}, [sayVoice, speechRate, previewAudio, t]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -304,7 +306,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: `Hello! I'm ${openaiVoice}. This is how I sound.`,
|
||||
text: t('settings.voice.page.preview.voiceLine', { voiceName: openaiVoice }),
|
||||
voice: openaiVoice,
|
||||
speed: speechRate,
|
||||
apiKey: openaiApiKey || undefined,
|
||||
@@ -337,7 +339,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
} catch {
|
||||
setIsOpenAIPreviewPlaying(false);
|
||||
}
|
||||
}, [openaiVoice, speechRate, openaiPreviewAudio, openaiApiKey]);
|
||||
}, [openaiVoice, speechRate, openaiPreviewAudio, openaiApiKey, t]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -364,7 +366,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: `Hello! This is a preview of the custom TTS server.`,
|
||||
text: t('settings.voice.page.preview.customServerLine'),
|
||||
voice: openaiCompatibleVoice,
|
||||
model: openaiCompatibleTtsModel || undefined,
|
||||
speed: speechRate,
|
||||
@@ -398,7 +400,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
} catch {
|
||||
setIsCompatiblePreviewPlaying(false);
|
||||
}
|
||||
}, [openaiCompatibleUrl, openaiCompatibleVoice, openaiCompatibleTtsModel, speechRate, compatiblePreviewAudio]);
|
||||
}, [openaiCompatibleUrl, openaiCompatibleVoice, openaiCompatibleTtsModel, speechRate, compatiblePreviewAudio, t]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -417,7 +419,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Voice Setup
|
||||
{t('settings.voice.page.section.voiceSetup')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -431,8 +433,8 @@ export const VoiceSettings: React.FC = () => {
|
||||
onClick={() => setVoiceModeEnabled(!voiceModeEnabled)}
|
||||
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setVoiceModeEnabled(!voiceModeEnabled); } }}
|
||||
>
|
||||
<Checkbox checked={voiceModeEnabled} onChange={setVoiceModeEnabled} ariaLabel="Enable voice mode" />
|
||||
<span className="typography-ui-label text-foreground">Enable Voice Mode</span>
|
||||
<Checkbox checked={voiceModeEnabled} onChange={setVoiceModeEnabled} ariaLabel={t('settings.voice.page.field.enableVoiceModeAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.enableVoiceMode')}</span>
|
||||
</div>
|
||||
|
||||
{voiceModeEnabled && (
|
||||
@@ -440,17 +442,17 @@ export const VoiceSettings: React.FC = () => {
|
||||
<div className="pb-1.5 pt-0.5">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="typography-ui-label text-foreground">Provider</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.provider')}</span>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
<ul className="space-y-1">
|
||||
<li><strong>Browser:</strong> Free, offline, limited mobile support.</li>
|
||||
<li><strong>OpenAI:</strong> High quality, mobile ready, needs API key.</li>
|
||||
<li><strong>Custom:</strong> OpenAI-compatible server (e.g. Kokoro).</li>
|
||||
<li><strong>Say:</strong> macOS native. Fast, free, offline.</li>
|
||||
<li><strong>{t('settings.voice.page.provider.browser')}</strong> {t('settings.voice.page.tooltip.browser')}</li>
|
||||
<li><strong>OpenAI:</strong> {t('settings.voice.page.tooltip.openai')}</li>
|
||||
<li><strong>{t('settings.voice.page.provider.custom')}</strong> {t('settings.voice.page.tooltip.custom')}</li>
|
||||
<li><strong>{t('settings.voice.page.provider.say')}</strong> {t('settings.voice.page.tooltip.say')}</li>
|
||||
</ul>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -463,7 +465,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
onClick={() => setVoiceProvider('browser')}
|
||||
className="!font-normal"
|
||||
>
|
||||
Browser
|
||||
{t('settings.voice.page.provider.browser')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="chip"
|
||||
@@ -481,7 +483,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
onClick={() => setVoiceProvider('openai-compatible')}
|
||||
className="!font-normal"
|
||||
>
|
||||
Custom
|
||||
{t('settings.voice.page.provider.custom')}
|
||||
</Button>
|
||||
{isSayAvailable && (
|
||||
<Button
|
||||
@@ -492,7 +494,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
className="!font-normal"
|
||||
>
|
||||
<RiAppleLine className="w-3.5 h-3.5 mr-0.5" />
|
||||
Say
|
||||
{t('settings.voice.page.provider.say')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -503,10 +505,14 @@ export const VoiceSettings: React.FC = () => {
|
||||
{voiceProvider === 'openai' && (
|
||||
<div className="py-1.5">
|
||||
<span className={cn("typography-ui-label text-foreground", !isOpenAIAvailable && "text-[var(--status-error)]")}>
|
||||
API Key
|
||||
{t('settings.voice.page.field.apiKey')}
|
||||
</span>
|
||||
<span className={cn("typography-meta ml-2", !isOpenAIAvailable ? "text-[var(--status-error)]/80" : "text-muted-foreground")}>
|
||||
{isOpenAIAvailable && !openaiApiKey ? 'Using key from configuration' : !isOpenAIAvailable ? 'OpenAI TTS requires an API key' : 'Provide your OpenAI key'}
|
||||
{isOpenAIAvailable && !openaiApiKey
|
||||
? t('settings.voice.page.field.apiKeyHintUsingConfig')
|
||||
: !isOpenAIAvailable
|
||||
? t('settings.voice.page.field.apiKeyHintRequired')
|
||||
: t('settings.voice.page.field.apiKeyHintProvide')}
|
||||
</span>
|
||||
<div className="relative mt-1.5 max-w-xs">
|
||||
<input
|
||||
@@ -534,10 +540,10 @@ export const VoiceSettings: React.FC = () => {
|
||||
<div className="py-1.5 space-y-2">
|
||||
<div>
|
||||
<span className={cn("typography-ui-label text-foreground", !openaiCompatibleUrl.trim() && "text-[var(--status-error)]")}>
|
||||
Server URL
|
||||
{t('settings.voice.page.field.serverUrl')}
|
||||
</span>
|
||||
<span className="typography-meta ml-2 text-muted-foreground">
|
||||
Base URL of the OpenAI-compatible TTS server
|
||||
{t('settings.voice.page.field.serverUrlHint')}
|
||||
</span>
|
||||
<div className="relative mt-1.5 max-w-xs">
|
||||
<input
|
||||
@@ -559,7 +565,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="typography-ui-label text-foreground">Model</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.model')}</span>
|
||||
<div className="relative mt-1.5 max-w-xs">
|
||||
<input
|
||||
type="text"
|
||||
@@ -571,9 +577,9 @@ export const VoiceSettings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="typography-ui-label text-foreground">Voice</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.voice')}</span>
|
||||
<span className="typography-meta ml-2 text-muted-foreground">
|
||||
Voice identifier supported by the server
|
||||
{t('settings.voice.page.field.voiceIdentifierHint')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<div className="relative max-w-xs flex-1">
|
||||
@@ -585,7 +591,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
className="w-full h-7 rounded-lg border border-input bg-transparent px-2 typography-ui-label text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50 focus:border-primary/70"
|
||||
/>
|
||||
</div>
|
||||
<Button size="xs" variant="ghost" onClick={previewCompatibleVoice} title="Preview" disabled={!openaiCompatibleUrl.trim()}>
|
||||
<Button size="xs" variant="ghost" onClick={previewCompatibleVoice} title={t('settings.voice.page.actions.preview')} disabled={!openaiCompatibleUrl.trim()}>
|
||||
{isCompatiblePreviewPlaying ? <RiStopLine className="w-3.5 h-3.5" /> : <RiPlayLine className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -595,13 +601,13 @@ export const VoiceSettings: React.FC = () => {
|
||||
|
||||
{/* Voice Selection */}
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">Voice</span>
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.voice')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
{voiceProvider === 'openai' && isOpenAIAvailable && (
|
||||
<>
|
||||
<Select value={openaiVoice} onValueChange={setOpenaiVoice}>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectValue placeholder="Select voice" />
|
||||
<SelectValue placeholder={t('settings.voice.page.field.selectVoicePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPENAI_VOICE_OPTIONS.map((v) => (
|
||||
@@ -609,21 +615,21 @@ export const VoiceSettings: React.FC = () => {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="xs" variant="ghost" onClick={previewOpenAIVoice} title="Preview">
|
||||
<Button size="xs" variant="ghost" onClick={previewOpenAIVoice} title={t('settings.voice.page.actions.preview')}>
|
||||
{isOpenAIPreviewPlaying ? <RiStopLine className="w-3.5 h-3.5" /> : <RiPlayLine className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{voiceProvider === 'openai-compatible' && (
|
||||
<span className="typography-meta text-muted-foreground">Configured above</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.voice.page.field.configuredAbove')}</span>
|
||||
)}
|
||||
|
||||
{voiceProvider === 'say' && isSayAvailable && sayVoices.length > 0 && (
|
||||
<>
|
||||
<Select value={sayVoice} onValueChange={setSayVoice}>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectValue placeholder="Select voice" />
|
||||
<SelectValue placeholder={t('settings.voice.page.field.selectVoicePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sayVoices.map((v) => (
|
||||
@@ -631,7 +637,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="xs" variant="ghost" onClick={previewVoice} title="Preview">
|
||||
<Button size="xs" variant="ghost" onClick={previewVoice} title={t('settings.voice.page.actions.preview')}>
|
||||
{isPreviewPlaying ? <RiStopLine className="w-3.5 h-3.5" /> : <RiPlayLine className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</>
|
||||
@@ -641,16 +647,16 @@ export const VoiceSettings: React.FC = () => {
|
||||
<>
|
||||
<Select value={browserVoice || '__auto__'} onValueChange={(value) => setBrowserVoice(value === '__auto__' ? '' : value)}>
|
||||
<SelectTrigger className="w-fit max-w-[200px]">
|
||||
<SelectValue placeholder="Auto" />
|
||||
<SelectValue placeholder={t('settings.voice.page.field.auto')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__auto__">Auto</SelectItem>
|
||||
<SelectItem value="__auto__">{t('settings.voice.page.field.auto')}</SelectItem>
|
||||
{filteredBrowserVoices.map((v) => (
|
||||
<SelectItem key={v.name} value={v.name}>{v.name} ({v.lang})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="xs" variant="ghost" onClick={previewBrowserVoice} title="Preview">
|
||||
<Button size="xs" variant="ghost" onClick={previewBrowserVoice} title={t('settings.voice.page.actions.preview')}>
|
||||
{isBrowserPreviewPlaying ? <RiStopLine className="w-3.5 h-3.5" /> : <RiPlayLine className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</>
|
||||
@@ -660,7 +666,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
|
||||
{/* Speech Rate */}
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">Speech Rate</span>
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.speechRate')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
{!isMobile && <input type="range" min={0.5} max={2} step={0.1} value={speechRate} onChange={(e) => setSpeechRate(Number(e.target.value))} disabled={!isSupported} className={sliderClass} />}
|
||||
<NumberInput value={speechRate} onValueChange={setSpeechRate} min={0.5} max={2} step={0.1} className="w-16 tabular-nums" />
|
||||
@@ -669,7 +675,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
|
||||
{/* Speech Pitch */}
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">Speech Pitch</span>
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.speechPitch')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
{!isMobile && <input type="range" min={0.5} max={2} step={0.1} value={speechPitch} onChange={(e) => setSpeechPitch(Number(e.target.value))} disabled={!isSupported} className={sliderClass} />}
|
||||
<NumberInput value={speechPitch} onValueChange={setSpeechPitch} min={0.5} max={2} step={0.1} className="w-16 tabular-nums" />
|
||||
@@ -678,7 +684,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
|
||||
{/* Speech Volume */}
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">Speech Volume</span>
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.speechVolume')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
{!isMobile && <input type="range" min={0} max={1} step={0.1} value={speechVolume} onChange={(e) => setSpeechVolume(Number(e.target.value))} disabled={!isSupported} className={sliderClass} />}
|
||||
{isMobile ? (
|
||||
@@ -693,11 +699,11 @@ export const VoiceSettings: React.FC = () => {
|
||||
|
||||
{/* Language */}
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">Language</span>
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.language')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
<Select value={language} onValueChange={setLanguage} disabled={!isSupported}>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectValue placeholder="Select language" />
|
||||
<SelectValue placeholder={t('settings.voice.page.field.selectLanguagePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANGUAGE_OPTIONS.map((lang) => (
|
||||
@@ -717,7 +723,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Speech Recognition
|
||||
{t('settings.voice.page.section.speechRecognition')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -725,15 +731,15 @@ export const VoiceSettings: React.FC = () => {
|
||||
<div className="pb-1.5 pt-0.5">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="typography-ui-label text-foreground">Provider</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.provider')}</span>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
<ul className="space-y-1">
|
||||
<li><strong>Browser:</strong> Web Speech API (Chrome/Edge). Free, no setup.</li>
|
||||
<li><strong>Server:</strong> OpenAI-compatible Whisper server. Better accuracy, any language.</li>
|
||||
<li><strong>{t('settings.voice.page.provider.browser')}</strong> {t('settings.voice.page.tooltip.sttBrowser')}</li>
|
||||
<li><strong>{t('settings.voice.page.provider.server')}</strong> {t('settings.voice.page.tooltip.sttServer')}</li>
|
||||
</ul>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -746,7 +752,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
onClick={() => setSttProvider('browser')}
|
||||
className="!font-normal"
|
||||
>
|
||||
Browser
|
||||
{t('settings.voice.page.provider.browser')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="chip"
|
||||
@@ -755,7 +761,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
onClick={() => setSttProvider('server')}
|
||||
className="!font-normal"
|
||||
>
|
||||
Server
|
||||
{t('settings.voice.page.provider.server')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -765,15 +771,15 @@ export const VoiceSettings: React.FC = () => {
|
||||
<div className="py-1.5 space-y-2">
|
||||
{!audioStreamService.isSupported() && (
|
||||
<p className="typography-meta text-[var(--status-error)]">
|
||||
MediaRecorder or AudioContext is not available in this browser. Server STT may not work.
|
||||
{t('settings.voice.page.field.sttBrowserSupportError')}
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<span className={cn("typography-ui-label text-foreground", !sttServerUrl.trim() && "text-[var(--status-error)]")}>
|
||||
Server URL
|
||||
{t('settings.voice.page.field.serverUrl')}
|
||||
</span>
|
||||
<span className="typography-meta ml-2 text-muted-foreground">
|
||||
Base URL of the Whisper-compatible server
|
||||
{t('settings.voice.page.field.sttServerUrlHint')}
|
||||
</span>
|
||||
<div className="relative mt-1.5 max-w-xs">
|
||||
<input
|
||||
@@ -795,7 +801,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="typography-ui-label text-foreground">Model</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.model')}</span>
|
||||
<div className="relative mt-1.5 max-w-xs">
|
||||
<input
|
||||
type="text"
|
||||
@@ -807,9 +813,9 @@ export const VoiceSettings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="typography-ui-label text-foreground">Language</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.language')}</span>
|
||||
<span className="typography-meta ml-2 text-muted-foreground">
|
||||
BCP-47 code (e.g. en, fr). Leave blank for auto-detect.
|
||||
{t('settings.voice.page.field.sttLanguageHint')}
|
||||
</span>
|
||||
<div className="relative mt-1.5 max-w-[8rem]">
|
||||
<input
|
||||
@@ -822,7 +828,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-8 py-0.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">Silence Threshold</span>
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.silenceThreshold')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
{!isMobile && <input type="range" min={-60} max={-20} step={1} value={sttSilenceThresholdDb} onChange={(e) => setSttSilenceThresholdDb(Number(e.target.value))} className={sliderClass} />}
|
||||
<span className="typography-ui-label text-foreground tabular-nums min-w-[3.5rem] text-right">
|
||||
@@ -831,11 +837,11 @@ export const VoiceSettings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-8 py-0.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">Silence Hold</span>
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.silenceHold')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
{!isMobile && <input type="range" min={500} max={3000} step={100} value={sttSilenceHoldMs} onChange={(e) => setSttSilenceHoldMs(Number(e.target.value))} className={sliderClass} />}
|
||||
<NumberInput value={sttSilenceHoldMs} onValueChange={setSttSilenceHoldMs} min={500} max={3000} step={100} className="w-20 tabular-nums" />
|
||||
<span className="typography-meta text-muted-foreground">ms</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.voice.page.field.millisecondsUnit')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -848,7 +854,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Playback & Summarization
|
||||
{t('settings.voice.page.section.playbackAndSummary')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -861,8 +867,8 @@ export const VoiceSettings: React.FC = () => {
|
||||
onClick={() => setShowMessageTTSButtons(!showMessageTTSButtons)}
|
||||
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setShowMessageTTSButtons(!showMessageTTSButtons); } }}
|
||||
>
|
||||
<Checkbox checked={showMessageTTSButtons} onChange={setShowMessageTTSButtons} ariaLabel="Message read aloud button" />
|
||||
<span className="typography-ui-label text-foreground">Message Read Aloud Button</span>
|
||||
<Checkbox checked={showMessageTTSButtons} onChange={setShowMessageTTSButtons} ariaLabel={t('settings.voice.page.field.messageReadAloudButtonAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.messageReadAloudButton')}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -873,8 +879,8 @@ export const VoiceSettings: React.FC = () => {
|
||||
onClick={() => setSummarizeMessageTTS(!summarizeMessageTTS)}
|
||||
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setSummarizeMessageTTS(!summarizeMessageTTS); } }}
|
||||
>
|
||||
<Checkbox checked={summarizeMessageTTS} onChange={setSummarizeMessageTTS} ariaLabel="Summarize before playback" />
|
||||
<span className="typography-ui-label text-foreground">Summarize Before Playback</span>
|
||||
<Checkbox checked={summarizeMessageTTS} onChange={setSummarizeMessageTTS} ariaLabel={t('settings.voice.page.field.summarizeBeforePlaybackAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.summarizeBeforePlayback')}</span>
|
||||
</div>
|
||||
|
||||
{voiceModeEnabled && (
|
||||
@@ -886,15 +892,15 @@ export const VoiceSettings: React.FC = () => {
|
||||
onClick={() => setSummarizeVoiceConversation(!summarizeVoiceConversation)}
|
||||
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setSummarizeVoiceConversation(!summarizeVoiceConversation); } }}
|
||||
>
|
||||
<Checkbox checked={summarizeVoiceConversation} onChange={setSummarizeVoiceConversation} ariaLabel="Summarize voice mode responses" />
|
||||
<span className="typography-ui-label text-foreground">Summarize Voice Mode Responses</span>
|
||||
<Checkbox checked={summarizeVoiceConversation} onChange={setSummarizeVoiceConversation} ariaLabel={t('settings.voice.page.field.summarizeVoiceModeResponsesAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.summarizeVoiceModeResponses')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(summarizeMessageTTS || summarizeVoiceConversation) && (
|
||||
<>
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">Summarization Threshold</span>
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.summarizationThreshold')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
{!isMobile && <input type="range" min={50} max={2000} step={50} value={summarizeCharacterThreshold} onChange={(e) => setSummarizeCharacterThreshold(Number(e.target.value))} className={sliderClass} />}
|
||||
<NumberInput value={summarizeCharacterThreshold} onValueChange={setSummarizeCharacterThreshold} min={50} max={2000} step={50} className="w-16 tabular-nums" />
|
||||
@@ -902,7 +908,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">Summary Max Length</span>
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.summaryMaxLength')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
{!isMobile && <input type="range" min={50} max={2000} step={50} value={summarizeMaxLength} onChange={(e) => setSummarizeMaxLength(Number(e.target.value))} className={sliderClass} />}
|
||||
<NumberInput value={summarizeMaxLength} onValueChange={setSummarizeMaxLength} min={50} max={2000} step={50} className="w-16 tabular-nums" />
|
||||
@@ -915,7 +921,13 @@ export const VoiceSettings: React.FC = () => {
|
||||
{voiceModeEnabled && isSupported && (
|
||||
<div className="mt-2 px-2">
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Press <kbd className="px-1 py-0.5 mx-0.5 rounded border border-[var(--interactive-border)] bg-background typography-mono text-[10px]">Shift</kbd> + <kbd className="px-1 py-0.5 mx-0.5 rounded border border-[var(--interactive-border)] bg-background typography-mono text-[10px]">Click</kbd> on the mic button to toggle continuous mode
|
||||
{t('settings.voice.page.hint.shiftClickPrefix')}
|
||||
{' '}
|
||||
<kbd className="px-1 py-0.5 mx-0.5 rounded border border-[var(--interactive-border)] bg-background typography-mono text-[10px]">Shift</kbd>
|
||||
{' + '}
|
||||
<kbd className="px-1 py-0.5 mx-0.5 rounded border border-[var(--interactive-border)] bg-background typography-mono text-[10px]">Click</kbd>
|
||||
{' '}
|
||||
{t('settings.voice.page.hint.shiftClickSuffix')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -14,12 +14,14 @@ import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { formatPathForDisplay, cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export interface WorktreeSectionContentProps {
|
||||
projectRef?: { id: string; path: string } | null;
|
||||
}
|
||||
|
||||
export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({ projectRef: projectRefProp = null }) => {
|
||||
const { t } = useI18n();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const activeProject = useProjectsStore((state) => state.getActiveProject());
|
||||
|
||||
@@ -251,7 +253,7 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
||||
if (!projectPath) {
|
||||
return (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Select a project to manage worktrees.
|
||||
{t('settings.openchamber.worktrees.state.selectProject')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -259,7 +261,7 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
||||
if (isGitRepoLocal === false) {
|
||||
return (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Worktree settings are only available for Git repositories.
|
||||
{t('settings.openchamber.worktrees.state.gitOnly')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -270,21 +272,24 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
||||
<div className="space-y-2">
|
||||
<div className="mb-1 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-normal text-foreground">Setup commands</h3>
|
||||
<h3 className="typography-ui-header font-normal text-foreground">{t('settings.openchamber.worktrees.setup.title')}</h3>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Run automatically inside the new worktree directory when a worktree is created.
|
||||
Use <code className="font-mono text-xs bg-sidebar-accent/50 px-1 rounded">$ROOT_PROJECT_PATH</code> for the project root.
|
||||
{t('settings.openchamber.worktrees.setup.tooltipPrefix')}
|
||||
{' '}
|
||||
<code className="font-mono text-xs bg-sidebar-accent/50 px-1 rounded">$ROOT_PROJECT_PATH</code>
|
||||
{' '}
|
||||
{t('settings.openchamber.worktrees.setup.tooltipSuffix')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoadingCommands ? (
|
||||
<p className="typography-meta text-muted-foreground px-1">Loading...</p>
|
||||
<p className="typography-meta text-muted-foreground px-1">{t('settings.openchamber.worktrees.setup.loading')}</p>
|
||||
) : (
|
||||
<div className="space-y-2 px-1">
|
||||
{setupCommands.map((command, index) => (
|
||||
@@ -293,7 +298,7 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
||||
value={command}
|
||||
onChange={(e) => handleSetupCommandChange(index, e.target.value)}
|
||||
onBlur={handleCommandBlur}
|
||||
placeholder="e.g., bun install"
|
||||
placeholder={t('settings.openchamber.worktrees.setup.commandPlaceholder')}
|
||||
className="h-7 w-[30rem] max-w-full font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
@@ -302,7 +307,7 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
||||
handleRemoveCommand(index);
|
||||
}}
|
||||
className="flex-shrink-0 flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label="Remove command"
|
||||
aria-label={t('settings.openchamber.worktrees.setup.removeCommandAria')}
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -316,7 +321,7 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
||||
onClick={handleAddCommand}
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
Add command
|
||||
{t('settings.openchamber.worktrees.setup.addCommand')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -326,23 +331,23 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
||||
<div className="space-y-2 border-t border-border/40 pt-4">
|
||||
<div className="mb-1 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-normal text-foreground">Existing worktrees</h3>
|
||||
<h3 className="typography-ui-header font-normal text-foreground">{t('settings.openchamber.worktrees.list.title')}</h3>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Worktrees live outside the repo (OpenCode-managed). Deleting a worktree also removes linked sessions.
|
||||
{t('settings.openchamber.worktrees.list.tooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoadingWorktrees ? (
|
||||
<p className="typography-meta text-muted-foreground px-1">Loading worktrees...</p>
|
||||
<p className="typography-meta text-muted-foreground px-1">{t('settings.openchamber.worktrees.list.loading')}</p>
|
||||
) : availableWorktrees.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground/70 px-1">
|
||||
No worktrees found for this project
|
||||
{t('settings.openchamber.worktrees.list.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1 px-1 max-w-[32.5rem]">
|
||||
@@ -354,7 +359,7 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<p className="typography-meta text-foreground truncate min-w-0">
|
||||
{worktree.label || worktree.branch || 'Detached HEAD'}
|
||||
{worktree.label || worktree.branch || t('settings.openchamber.worktrees.list.detachedHead')}
|
||||
</p>
|
||||
<span className="typography-micro text-muted-foreground/60 px-1.5 py-[1px] rounded bg-sidebar-accent/40 flex-shrink-0 self-center leading-none">
|
||||
OpenCode
|
||||
@@ -371,7 +376,7 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
||||
"flex-shrink-0 flex h-7 w-7 items-center justify-center rounded text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50",
|
||||
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
||||
)}
|
||||
aria-label={`Delete worktree ${worktree.branch || worktree.label}`}
|
||||
aria-label={t('settings.openchamber.worktrees.list.deleteWorktreeAria', { name: worktree.branch || worktree.label || worktree.path })}
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
PROJECT_ACTION_ICONS,
|
||||
PROJECT_ACTIONS_UPDATED_EVENT,
|
||||
} from '@/lib/projectActions';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type EditableProjectAction = OpenChamberProjectAction;
|
||||
@@ -67,6 +68,7 @@ interface ProjectActionsSectionProps {
|
||||
}
|
||||
|
||||
export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ projectRef }) => {
|
||||
const { t } = useI18n();
|
||||
const isDesktopShellApp = React.useMemo(() => isDesktopShell(), []);
|
||||
const desktopSshInstances = useDesktopSshStore((state) => state.instances);
|
||||
const loadDesktopSsh = useDesktopSshStore((state) => state.load);
|
||||
@@ -126,10 +128,10 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
return entry.name.trim().length === 0 || entry.command.trim().length === 0;
|
||||
});
|
||||
if (hasIncomplete) {
|
||||
return 'Fill action name and command before saving.';
|
||||
return t('settings.projects.actions.validation.fillNameAndCommand');
|
||||
}
|
||||
return null;
|
||||
}, [actions]);
|
||||
}, [actions, t]);
|
||||
|
||||
const hasChanges = React.useMemo(() => {
|
||||
if (initialSnapshot === null) {
|
||||
@@ -172,7 +174,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
primaryActionId: null,
|
||||
});
|
||||
if (!ok) {
|
||||
toast.error('Failed to save actions');
|
||||
toast.error(t('settings.projects.actions.toast.saveFailed'));
|
||||
return;
|
||||
}
|
||||
setInitialSnapshot(JSON.stringify({ actions }));
|
||||
@@ -181,13 +183,13 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
detail: { projectId: projectRef.id },
|
||||
}));
|
||||
}
|
||||
toast.success('Project actions saved');
|
||||
toast.success(t('settings.projects.actions.toast.saved'));
|
||||
} catch {
|
||||
toast.error('Failed to save actions');
|
||||
toast.error(t('settings.projects.actions.toast.saveFailed'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [actions, projectRef, validationError]);
|
||||
}, [actions, projectRef, t, validationError]);
|
||||
|
||||
const canSave = !isSaving && !isLoading && hasChanges && !validationError;
|
||||
|
||||
@@ -195,21 +197,21 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Actions</h3>
|
||||
<p className="typography-meta text-muted-foreground">Per-project commands shown in header next to project name.</p>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.projects.actions.title')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.projects.actions.description')}</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={handleAddAction}>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
Add action
|
||||
{t('settings.projects.actions.actions.add')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<section className="pb-2 pt-0 space-y-2">
|
||||
{isLoading ? (
|
||||
<p className="typography-meta text-muted-foreground">Loading...</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.projects.actions.state.loading')}</p>
|
||||
) : actions.length === 0 ? (
|
||||
<div className="py-2">
|
||||
<p className="typography-meta text-muted-foreground">No actions configured yet.</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.projects.actions.state.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0 max-w-[30rem]">
|
||||
@@ -217,7 +219,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
const selectedIconKey = (action.icon as keyof typeof PROJECT_ACTION_ICON_MAP) || 'play';
|
||||
const SelectedIcon = PROJECT_ACTION_ICON_MAP[selectedIconKey] || RiPlayLine;
|
||||
const isOpen = expandedActions[action.id] ?? false;
|
||||
const title = action.name.trim() || 'Untitled action';
|
||||
const title = action.name.trim() || t('settings.projects.actions.state.untitled');
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
@@ -267,7 +269,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-[var(--interactive-border)] text-foreground hover:bg-[var(--interactive-hover)]"
|
||||
aria-label="Select icon"
|
||||
aria-label={t('settings.projects.actions.field.selectIconAria')}
|
||||
>
|
||||
<SelectedIcon className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -286,7 +288,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
'inline-flex h-8 w-8 items-center justify-center rounded-md border border-transparent text-foreground hover:bg-[var(--interactive-hover)]',
|
||||
selected && 'border-[var(--primary-base)] bg-[var(--primary-base)]/10 text-[var(--primary-base)]'
|
||||
)}
|
||||
aria-label={`Icon ${entry.label}`}
|
||||
aria-label={t('settings.projects.actions.field.iconAria', { icon: entry.label })}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -299,24 +301,24 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
<Input
|
||||
value={action.name}
|
||||
onChange={(event) => updateAction(action.id, (current) => ({ ...current, name: event.target.value }))}
|
||||
placeholder="Action name"
|
||||
placeholder={t('settings.projects.actions.field.actionNamePlaceholder')}
|
||||
className="h-7 max-w-[14rem]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="py-1">
|
||||
<p className="typography-meta mb-0.5 text-muted-foreground">Command</p>
|
||||
<p className="typography-meta mb-0.5 text-muted-foreground">{t('settings.projects.actions.field.command')}</p>
|
||||
<Textarea
|
||||
value={action.command}
|
||||
onChange={(event) => updateAction(action.id, (current) => ({ ...current, command: event.target.value }))}
|
||||
placeholder="e.g. bun run lint"
|
||||
placeholder={t('settings.projects.actions.field.commandPlaceholder')}
|
||||
className="min-h-[88px] max-w-[30rem] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="py-1">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="typography-ui-label text-foreground">Auto-open URL</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.projects.actions.field.autoOpenUrl')}</span>
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2"
|
||||
role="button"
|
||||
@@ -342,9 +344,9 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
...current,
|
||||
...(checked ? { autoOpenUrl: true } : { autoOpenUrl: undefined }),
|
||||
}))}
|
||||
ariaLabel={`Auto-open URL for ${title}`}
|
||||
ariaLabel={t('settings.projects.actions.field.autoOpenUrlForAria', { title })}
|
||||
/>
|
||||
<span className="typography-ui-label font-normal text-foreground/80">Open URL from output or custom URL below</span>
|
||||
<span className="typography-ui-label font-normal text-foreground/80">{t('settings.projects.actions.field.autoOpenUrlDescription')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -357,7 +359,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
...current,
|
||||
openUrl: event.target.value,
|
||||
}))}
|
||||
placeholder="Override URL (optional)"
|
||||
placeholder={t('settings.projects.actions.field.overrideUrlPlaceholder')}
|
||||
className="h-7 w-full max-w-[24rem]"
|
||||
/>
|
||||
<Tooltip delayDuration={1000}>
|
||||
@@ -365,14 +367,14 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
<RiInformationLine className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
If this field is filled, custom URL is used. If empty, app opens best URL from output.
|
||||
{t('settings.projects.actions.field.overrideUrlTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{isDesktopShellApp ? (
|
||||
<div className="mt-2">
|
||||
<p className="typography-meta mb-0.5 text-muted-foreground">Desktop SSH forward</p>
|
||||
<p className="typography-meta mb-0.5 text-muted-foreground">{t('settings.projects.actions.field.desktopSshForward')}</p>
|
||||
{desktopForwardOptions.length > 0 ? (
|
||||
<Select
|
||||
value={
|
||||
@@ -388,17 +390,17 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-full max-w-[30rem]">
|
||||
<SelectValue placeholder="Use output/manual URL" />
|
||||
<SelectValue placeholder={t('settings.projects.actions.field.useOutputManualUrl')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">Use output/manual URL</SelectItem>
|
||||
<SelectItem value="__none__">{t('settings.projects.actions.field.useOutputManualUrl')}</SelectItem>
|
||||
{desktopForwardOptions.map((entry) => (
|
||||
<SelectItem key={entry.id} value={entry.id}>{entry.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="typography-meta text-muted-foreground">No enabled local SSH forwards available.</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.projects.actions.state.noDesktopSshForwards')}</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -425,7 +427,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
onClick={handleSave}
|
||||
disabled={!canSave}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Actions'}
|
||||
{isSaving ? t('settings.common.actions.saving') : t('settings.projects.actions.actions.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -11,8 +11,10 @@ import { RiCloseLine } from '@remixicon/react';
|
||||
import { WorktreeSectionContent } from '@/components/sections/openchamber/WorktreeSectionContent';
|
||||
import { ProjectActionsSection } from '@/components/sections/projects/ProjectActionsSection';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export const ProjectsPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const updateProjectMeta = useProjectsStore((state) => state.updateProjectMeta);
|
||||
const uploadProjectIcon = useProjectsStore((state) => state.uploadProjectIcon);
|
||||
@@ -108,10 +110,10 @@ export const ProjectsPage: React.FC = () => {
|
||||
const uploadResult = await uploadProjectIcon(selectedProject.id, pendingUploadIconFile);
|
||||
setIsUploadingIcon(false);
|
||||
if (!uploadResult.ok) {
|
||||
toast.error(uploadResult.error || 'Failed to upload project icon');
|
||||
toast.error(uploadResult.error || t('settings.projects.page.toast.uploadIconFailed'));
|
||||
return;
|
||||
}
|
||||
toast.success('Project icon updated');
|
||||
toast.success(t('settings.projects.page.toast.iconUpdated'));
|
||||
clearPendingUploadIcon();
|
||||
setPendingRemoveImageIcon(false);
|
||||
}
|
||||
@@ -123,10 +125,10 @@ export const ProjectsPage: React.FC = () => {
|
||||
const removeResult = await removeProjectIcon(selectedProject.id);
|
||||
setIsRemovingCustomIcon(false);
|
||||
if (!removeResult.ok) {
|
||||
toast.error(removeResult.error || 'Failed to remove project icon');
|
||||
toast.error(removeResult.error || t('settings.projects.page.toast.removeIconFailed'));
|
||||
return;
|
||||
}
|
||||
toast.success('Project icon removed');
|
||||
toast.success(t('settings.projects.page.toast.iconRemoved'));
|
||||
setPendingRemoveImageIcon(false);
|
||||
setIconBackground(null);
|
||||
}
|
||||
@@ -220,25 +222,25 @@ export const ProjectsPage: React.FC = () => {
|
||||
void discoverProjectIcon(selectedProject.id)
|
||||
.then((result) => {
|
||||
if (!result.ok) {
|
||||
toast.error(result.error || 'Failed to discover project icon');
|
||||
toast.error(result.error || t('settings.projects.page.toast.discoverIconFailed'));
|
||||
return;
|
||||
}
|
||||
if (result.skipped) {
|
||||
toast.success('Custom icon already set for this project');
|
||||
toast.success(t('settings.projects.page.toast.customIconAlreadySet'));
|
||||
return;
|
||||
}
|
||||
toast.success('Project icon discovered');
|
||||
toast.success(t('settings.projects.page.toast.iconDiscovered'));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsDiscoveringIcon(false);
|
||||
});
|
||||
}, [clearPendingUploadIcon, discoverProjectIcon, isDiscoveringIcon, selectedProject]);
|
||||
}, [clearPendingUploadIcon, discoverProjectIcon, isDiscoveringIcon, selectedProject, t]);
|
||||
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="h-full" className="w-full">
|
||||
<div className="mx-auto w-full max-w-4xl p-3 sm:p-6 sm:pt-8">
|
||||
<p className="typography-meta text-muted-foreground">No projects available.</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.projects.page.empty.noProjects')}</p>
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
@@ -251,7 +253,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground truncate">
|
||||
{selectedProject.label ?? 'Project Settings'}
|
||||
{selectedProject.label ?? t('settings.projects.page.title.default')}
|
||||
</h2>
|
||||
<p className="typography-meta text-muted-foreground truncate" title={selectedProject.path}>
|
||||
{selectedProject.path}
|
||||
@@ -266,13 +268,13 @@ export const ProjectsPage: React.FC = () => {
|
||||
{/* Name */}
|
||||
<div className="py-1.5">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="typography-ui-label text-foreground">Project Name</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.projects.page.field.projectName')}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex min-w-0 items-center gap-2">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Project name"
|
||||
placeholder={t('settings.projects.page.field.projectNamePlaceholder')}
|
||||
className="h-7 min-w-0 w-full sm:max-w-[19rem]"
|
||||
/>
|
||||
</div>
|
||||
@@ -281,7 +283,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
{/* Color */}
|
||||
<div className="py-1.5">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="typography-ui-label text-foreground">Accent Color</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.projects.page.field.accentColor')}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
@@ -293,7 +295,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
? 'border-2 border-foreground bg-[var(--primary-base)]/10'
|
||||
: 'border-border/40 hover:border-border hover:bg-[var(--surface-muted)]'
|
||||
)}
|
||||
title="None"
|
||||
title={t('settings.projects.page.field.none')}
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
@@ -318,7 +320,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
{/* Icon */}
|
||||
<div className="py-1.5">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="typography-ui-label text-foreground">Project Icon</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.projects.page.field.projectIcon')}</span>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -341,7 +343,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
? 'border-2 border-foreground bg-[var(--primary-base)]/10'
|
||||
: 'border-border/40 hover:border-border hover:bg-[var(--surface-muted)]'
|
||||
)}
|
||||
title="None"
|
||||
title={t('settings.projects.page.field.none')}
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
@@ -367,7 +369,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
</div>
|
||||
{effectiveHasImageIcon && iconPreviewUrl && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="typography-meta text-muted-foreground">Preview</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.projects.page.field.preview')}</span>
|
||||
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-border/60 bg-[var(--surface-elevated)] p-1">
|
||||
<span
|
||||
className="inline-flex h-4 w-4 items-center justify-center overflow-hidden rounded-[2px]"
|
||||
@@ -391,7 +393,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
value={iconBackground ?? '#000000'}
|
||||
onChange={(event) => setIconBackground(event.target.value)}
|
||||
className="h-7 w-9 cursor-pointer rounded border border-border bg-transparent p-1"
|
||||
aria-label="Project icon background color"
|
||||
aria-label={t('settings.projects.page.field.projectIconBackgroundAria')}
|
||||
/>
|
||||
<Input
|
||||
value={iconBackground ?? ''}
|
||||
@@ -405,8 +407,8 @@ export const ProjectsPage: React.FC = () => {
|
||||
variant="outline"
|
||||
onClick={() => setIconBackground(null)}
|
||||
className="h-7 w-7 p-0"
|
||||
aria-label="Clear icon background"
|
||||
title="Clear background"
|
||||
aria-label={t('settings.projects.page.field.clearIconBackgroundAria')}
|
||||
title={t('settings.projects.page.field.clearBackground')}
|
||||
disabled={!iconBackground}
|
||||
>
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
@@ -422,7 +424,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploadingIcon}
|
||||
>
|
||||
{isUploadingIcon ? 'Uploading...' : 'Upload Icon'}
|
||||
{isUploadingIcon ? t('settings.projects.page.actions.uploading') : t('settings.projects.page.actions.uploadIcon')}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -431,7 +433,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
onClick={() => void handleDiscoverIcon()}
|
||||
disabled={isDiscoveringIcon}
|
||||
>
|
||||
{isDiscoveringIcon ? 'Discovering...' : 'Discover Favicon'}
|
||||
{isDiscoveringIcon ? t('settings.projects.page.actions.discovering') : t('settings.projects.page.actions.discoverFavicon')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -443,7 +445,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
onClick={() => void handleRemoveImageIcon()}
|
||||
disabled={isRemovingCustomIcon}
|
||||
>
|
||||
{isRemovingCustomIcon ? 'Removing...' : 'Remove Project Icon'}
|
||||
{isRemovingCustomIcon ? t('settings.projects.page.actions.removing') : t('settings.projects.page.actions.removeProjectIcon')}
|
||||
</Button>
|
||||
)}
|
||||
{pendingRemoveImageIcon && (
|
||||
@@ -454,7 +456,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
onClick={() => setPendingRemoveImageIcon(false)}
|
||||
disabled={isRemovingCustomIcon}
|
||||
>
|
||||
Undo Remove
|
||||
{t('settings.projects.page.actions.undoRemove')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -469,7 +471,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
>
|
||||
Save Changes
|
||||
{t('settings.common.actions.saveChanges')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -485,7 +487,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Worktree
|
||||
{t('settings.projects.page.section.worktree')}
|
||||
</h3>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
|
||||
@@ -11,8 +11,10 @@ import { isDesktopLocalOriginActive, isTauriShell, isVSCodeRuntime, requestDirec
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onItemSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const addProject = useProjectsStore((state) => state.addProject);
|
||||
const selectedId = useUIStore((state) => state.settingsProjectsSelectedId);
|
||||
@@ -34,23 +36,23 @@ export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onIte
|
||||
if (result.success && result.path) {
|
||||
const added = addProject(result.path, { id: result.projectId });
|
||||
if (!added) {
|
||||
toast.error('Failed to add project', {
|
||||
description: 'Please select a valid directory.',
|
||||
toast.error(t('sessions.sidebar.directory.errorAddProjectTitle'), {
|
||||
description: t('sessions.sidebar.directory.errorAddProjectDescription'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSelectedId(added.id);
|
||||
} else if (result.error && result.error !== 'Directory selection cancelled') {
|
||||
toast.error('Failed to select directory', {
|
||||
toast.error(t('sessions.sidebar.directory.errorSelectDirectoryTitle'), {
|
||||
description: result.error,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to select directory:', error);
|
||||
toast.error('Failed to select directory');
|
||||
toast.error(t('sessions.sidebar.directory.errorSelectDirectoryTitle'));
|
||||
});
|
||||
}, [addProject, setSelectedId, tauriIpcAvailable]);
|
||||
}, [addProject, setSelectedId, tauriIpcAvailable, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (projects.length === 0) {
|
||||
@@ -70,9 +72,9 @@ export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onIte
|
||||
variant="background"
|
||||
header={
|
||||
<div className={cn('border-b px-3', 'pt-4 pb-3')}>
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">Projects</h2>
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.page.projects.title')}</h2>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">Total {projects.length}</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.projects.sidebar.total', { count: projects.length })}</span>
|
||||
{!isVSCode && (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -80,7 +82,7 @@ export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onIte
|
||||
size="icon"
|
||||
className="h-7 w-7 -my-1 text-muted-foreground"
|
||||
onClick={handleAddProject}
|
||||
aria-label="Add project"
|
||||
aria-label={t('settings.projects.sidebar.actions.addProject')}
|
||||
>
|
||||
<RiAddLine className="size-4" />
|
||||
</Button>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { cn } from '@/lib/utils';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import type { ModelMetadata } from '@/types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
@@ -139,6 +140,7 @@ const parseProvidersPayload = (payload: unknown): ProviderOption[] => {
|
||||
};
|
||||
|
||||
export const ProvidersPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
const selectedProviderId = useConfigStore((state) => state.selectedProviderId);
|
||||
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
|
||||
@@ -192,7 +194,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
} catch (error) {
|
||||
if (!isMounted) return;
|
||||
console.error('Failed to load provider auth methods:', error);
|
||||
toast.error('Failed to load provider authentication methods');
|
||||
toast.error(t('settings.providers.page.toast.authMethodsLoadFailed'));
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setAuthLoading(false);
|
||||
@@ -229,7 +231,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
} catch (error) {
|
||||
if (!isMounted) return;
|
||||
console.error('Failed to load available providers:', error);
|
||||
setAvailableError('Unable to load provider list');
|
||||
setAvailableError(t('settings.providers.page.state.unableToLoadProviderList'));
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setAvailableLoading(false);
|
||||
@@ -296,7 +298,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error || 'Failed to load provider sources');
|
||||
throw new Error(payload?.error || t('settings.providers.page.toast.providerSourcesLoadFailed'));
|
||||
}
|
||||
|
||||
const sources = (payload?.sources ?? payload?.data?.sources) as ProviderSources | undefined;
|
||||
@@ -326,7 +328,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
const handleSaveApiKey = async (providerId: string) => {
|
||||
const apiKey = apiKeyInputs[providerId]?.trim() ?? '';
|
||||
if (!apiKey) {
|
||||
toast.error('API key is required');
|
||||
toast.error(t('settings.providers.page.toast.apiKeyRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -342,17 +344,17 @@ export const ProvidersPage: React.FC = () => {
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const message = payload?.error || 'Failed to save API key';
|
||||
const message = payload?.error || t('settings.providers.page.toast.apiKeySaveFailed');
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
toast.success('API key saved');
|
||||
toast.success(t('settings.providers.page.toast.apiKeySaved'));
|
||||
setApiKeyInputs((prev) => ({ ...prev, [providerId]: '' }));
|
||||
await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" });
|
||||
setSelectedProvider(providerId);
|
||||
} catch (error) {
|
||||
console.error('Failed to save API key:', error);
|
||||
toast.error('Failed to save API key');
|
||||
toast.error(t('settings.providers.page.toast.apiKeySaveFailed'));
|
||||
} finally {
|
||||
setAuthBusyKey(null);
|
||||
}
|
||||
@@ -371,7 +373,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const message = payload?.error || 'Failed to start OAuth flow';
|
||||
const message = payload?.error || t('settings.providers.page.toast.oauthStartFailed');
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
@@ -393,7 +395,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
undefined;
|
||||
|
||||
if (!urlCandidate && !instructions && !userCode) {
|
||||
throw new Error('No OAuth details returned');
|
||||
throw new Error(t('settings.providers.page.toast.oauthDetailsMissing'));
|
||||
}
|
||||
|
||||
const detailsKey = `${providerId}:${methodIndex}`;
|
||||
@@ -410,10 +412,10 @@ export const ProvidersPage: React.FC = () => {
|
||||
void openExternalUrl(urlCandidate);
|
||||
}
|
||||
setPendingOAuth({ providerId, methodIndex });
|
||||
toast.message('Complete the OAuth flow in your browser');
|
||||
toast.message(t('settings.providers.page.toast.completeOAuthInBrowser'));
|
||||
} catch (error) {
|
||||
console.error('Failed to start OAuth flow:', error);
|
||||
toast.error('Failed to start OAuth flow');
|
||||
toast.error(t('settings.providers.page.toast.oauthStartFailed'));
|
||||
} finally {
|
||||
setAuthBusyKey(null);
|
||||
}
|
||||
@@ -440,18 +442,18 @@ export const ProvidersPage: React.FC = () => {
|
||||
|
||||
const responsePayload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const message = responsePayload?.error || 'Failed to complete OAuth flow';
|
||||
const message = responsePayload?.error || t('settings.providers.page.toast.oauthCompleteFailed');
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
toast.success('OAuth connection completed');
|
||||
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('Failed to complete OAuth flow');
|
||||
toast.error(t('settings.providers.page.toast.oauthCompleteFailed'));
|
||||
} finally {
|
||||
setAuthBusyKey(null);
|
||||
}
|
||||
@@ -460,21 +462,21 @@ export const ProvidersPage: React.FC = () => {
|
||||
const handleCopyOAuthLink = async (url: string) => {
|
||||
const result = await copyTextToClipboard(url);
|
||||
if (result.ok) {
|
||||
toast.success('OAuth link copied');
|
||||
toast.success(t('settings.providers.page.toast.oauthLinkCopied'));
|
||||
return;
|
||||
}
|
||||
console.error('Failed to copy OAuth link:', result.error);
|
||||
toast.error('Failed to copy OAuth link');
|
||||
toast.error(t('settings.providers.page.toast.oauthLinkCopyFailed'));
|
||||
};
|
||||
|
||||
const handleCopyOAuthCode = async (code: string) => {
|
||||
const result = await copyTextToClipboard(code);
|
||||
if (result.ok) {
|
||||
toast.success('Device code copied');
|
||||
toast.success(t('settings.providers.page.toast.deviceCodeCopied'));
|
||||
return;
|
||||
}
|
||||
console.error('Failed to copy device code:', result.error);
|
||||
toast.error('Failed to copy device code');
|
||||
toast.error(t('settings.providers.page.toast.deviceCodeCopyFailed'));
|
||||
};
|
||||
|
||||
const handleDisconnectProvider = async (providerId: string) => {
|
||||
@@ -489,15 +491,15 @@ export const ProvidersPage: React.FC = () => {
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const message = payload?.error || 'Failed to disconnect provider';
|
||||
const message = payload?.error || t('settings.providers.page.toast.providerDisconnectFailed');
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
toast.success('Provider disconnected');
|
||||
toast.success(t('settings.providers.page.toast.providerDisconnected'));
|
||||
await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" });
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect provider:', error);
|
||||
toast.error('Failed to disconnect provider');
|
||||
toast.error(t('settings.providers.page.toast.providerDisconnectFailed'));
|
||||
} finally {
|
||||
setAuthBusyKey(null);
|
||||
}
|
||||
@@ -510,8 +512,8 @@ export const ProvidersPage: React.FC = () => {
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<RiStackLine className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
||||
<p className="typography-body">No providers detected</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Check your OpenCode configuration</p>
|
||||
<p className="typography-body">{t('settings.providers.page.empty.noProvidersDetected')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.providers.page.empty.checkOpenCodeConfiguration')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -522,23 +524,23 @@ export const ProvidersPage: React.FC = () => {
|
||||
<ScrollableOverlay outerClassName="h-full" className="w-full">
|
||||
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
|
||||
<div className="mb-4">
|
||||
<h1 className="typography-ui-header font-semibold text-foreground">Connect Provider</h1>
|
||||
<h1 className="typography-ui-header font-semibold text-foreground">{t('settings.providers.page.connect.title')}</h1>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h2 className="typography-ui-header font-medium text-foreground">Select Provider</h2>
|
||||
<h2 className="typography-ui-header font-medium text-foreground">{t('settings.providers.page.connect.selectProviderTitle')}</h2>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
<div className="flex flex-wrap items-center gap-2 py-1.5">
|
||||
<span className="typography-ui-label text-foreground">Provider</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.providers.page.connect.providerField')}</span>
|
||||
{availableLoading ? (
|
||||
<p className="typography-meta text-muted-foreground">Loading...</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.state.loading')}</p>
|
||||
) : availableError ? (
|
||||
<p className="typography-meta text-muted-foreground">{availableError}</p>
|
||||
) : unconnectedProviders.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">All providers connected.</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.connect.allProvidersConnected')}</p>
|
||||
) : (
|
||||
<DropdownMenu open={providerDropdownOpen} onOpenChange={(open) => {
|
||||
setProviderDropdownOpen(open);
|
||||
@@ -556,7 +558,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
<span className={cn("truncate typography-ui-label font-normal", candidateProviderId ? "text-foreground" : "text-muted-foreground")}>
|
||||
{candidateProviderId
|
||||
? (unconnectedProviders.find(p => p.id === candidateProviderId)?.name || candidateProviderId)
|
||||
: "Select provider"}
|
||||
: t('settings.providers.page.connect.selectProviderPlaceholder')}
|
||||
</span>
|
||||
</span>
|
||||
<RiArrowDownSLine className="h-4 w-4 flex-shrink-0 text-muted-foreground/50" />
|
||||
@@ -577,7 +579,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
value={providerSearchQuery}
|
||||
onChange={(e) => setProviderSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
placeholder="Search..."
|
||||
placeholder={t('settings.providers.page.connect.searchProvidersPlaceholder')}
|
||||
className="flex-1 bg-transparent typography-meta outline-none placeholder:text-muted-foreground"
|
||||
autoFocus
|
||||
/>
|
||||
@@ -589,7 +591,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
return (p.name || p.id).toLowerCase().includes(query) || p.id.toLowerCase().includes(query);
|
||||
});
|
||||
if (filtered.length === 0) {
|
||||
return <p className="py-4 text-center typography-meta text-muted-foreground">No providers found</p>;
|
||||
return <p className="py-4 text-center typography-meta text-muted-foreground">{t('settings.providers.page.connect.noProvidersFound')}</p>;
|
||||
}
|
||||
return filtered.map((provider) => (
|
||||
<DropdownMenuItem
|
||||
@@ -622,22 +624,22 @@ export const ProvidersPage: React.FC = () => {
|
||||
{candidateProviderId && (
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h2 className="typography-ui-header font-medium text-foreground">Authentication</h2>
|
||||
<h2 className="typography-ui-header font-medium text-foreground">{t('settings.providers.page.auth.title')}</h2>
|
||||
</div>
|
||||
|
||||
{authLoading ? (
|
||||
<p className="typography-meta text-muted-foreground px-2">Loading authentication methods...</p>
|
||||
<p className="typography-meta text-muted-foreground px-2">{t('settings.providers.page.auth.loadingMethods')}</p>
|
||||
) : (
|
||||
<section className="px-2 pb-2 pt-0 space-y-4">
|
||||
<div className="py-1.5">
|
||||
<label className="typography-ui-label text-foreground flex items-center gap-1.5">
|
||||
API Key
|
||||
{t('settings.providers.page.auth.apiKeyLabel')}
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Keys are sent directly to OpenCode and never stored by OpenChamber.
|
||||
{t('settings.providers.page.auth.apiKeyTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
@@ -651,7 +653,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
[candidateProviderId]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="sk-..."
|
||||
placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')}
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
@@ -660,7 +662,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
onClick={() => handleSaveApiKey(candidateProviderId)}
|
||||
disabled={authBusyKey === `api:${candidateProviderId}`}
|
||||
>
|
||||
{authBusyKey === `api:${candidateProviderId}` ? 'Saving...' : 'Save Key'}
|
||||
{authBusyKey === `api:${candidateProviderId}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.saveKey')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -678,7 +680,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
return (
|
||||
<div className="space-y-4 border-t border-[var(--surface-subtle)] pt-2">
|
||||
{candidateOAuthMethods.map((method, index) => {
|
||||
const methodLabel = method.label || method.name || `OAuth method ${index + 1}`;
|
||||
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;
|
||||
@@ -701,7 +703,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
onClick={() => handleOAuthStart(candidateProviderId, index)}
|
||||
disabled={authBusyKey === `oauth:${candidateProviderId}:${index}`}
|
||||
>
|
||||
Connect
|
||||
{t('settings.providers.page.actions.connect')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -714,7 +716,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
{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 ?? '')}>Copy Code</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>{t('settings.providers.page.actions.copyCode')}</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -722,8 +724,8 @@ export const ProvidersPage: React.FC = () => {
|
||||
<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 ?? '')}>Open</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>Copy</Button>
|
||||
<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>
|
||||
)}
|
||||
@@ -738,7 +740,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
[codeKey]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Paste authorization code"
|
||||
placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
@@ -747,7 +749,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
onClick={() => handleOAuthComplete(candidateProviderId, index)}
|
||||
disabled={authBusyKey === `oauth-complete:${candidateProviderId}:${index}`}
|
||||
>
|
||||
{authBusyKey === `oauth-complete:${candidateProviderId}:${index}` ? 'Saving...' : 'Complete'}
|
||||
{authBusyKey === `oauth-complete:${candidateProviderId}:${index}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.complete')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -771,8 +773,8 @@ export const ProvidersPage: React.FC = () => {
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<RiStackLine className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
||||
<p className="typography-body">Select a provider from the sidebar</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Review details and configure auth</p>
|
||||
<p className="typography-body">{t('settings.providers.page.empty.selectProviderFromSidebar')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.providers.page.empty.reviewDetailsAndConfigureAuth')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -810,14 +812,14 @@ export const ProvidersPage: React.FC = () => {
|
||||
{/* Authentication */}
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1 flex items-center justify-between gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Authentication</h3>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.providers.page.auth.title')}</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => setShowAuthPanel((prev) => !prev)}
|
||||
>
|
||||
{showAuthPanel ? 'Hide' : 'Reconnect'}
|
||||
{showAuthPanel ? t('settings.providers.page.actions.hide') : t('settings.providers.page.actions.reconnect')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -825,22 +827,22 @@ export const ProvidersPage: React.FC = () => {
|
||||
{!showAuthPanel ? (
|
||||
<div className="flex items-center gap-1.5 py-1.5">
|
||||
<RiCheckLine className="w-4 h-4 text-[var(--status-success)] shrink-0" />
|
||||
<span className="typography-ui-label text-foreground">Connected</span>
|
||||
<span className="typography-meta text-muted-foreground ml-1">· Use Reconnect to update credentials</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.providers.page.auth.connected')}</span>
|
||||
<span className="typography-meta text-muted-foreground ml-1">{t('settings.providers.page.auth.useReconnectHint')}</span>
|
||||
</div>
|
||||
) : authLoading ? (
|
||||
<div className="py-1.5 typography-meta text-muted-foreground">Loading authentication methods...</div>
|
||||
<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">
|
||||
API Key
|
||||
{t('settings.providers.page.auth.apiKeyLabel')}
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Keys are sent directly to OpenCode and never stored by OpenChamber.
|
||||
{t('settings.providers.page.auth.apiKeyTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
@@ -854,7 +856,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
[selectedProvider.id]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="sk-..."
|
||||
placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')}
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
@@ -863,7 +865,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
onClick={() => handleSaveApiKey(selectedProvider.id)}
|
||||
disabled={authBusyKey === `api:${selectedProvider.id}`}
|
||||
>
|
||||
{authBusyKey === `api:${selectedProvider.id}` ? 'Saving...' : 'Save Key'}
|
||||
{authBusyKey === `api:${selectedProvider.id}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.saveKey')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -871,7 +873,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
{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 || `OAuth method ${index + 1}`;
|
||||
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;
|
||||
@@ -894,7 +896,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
onClick={() => handleOAuthStart(selectedProvider.id, index)}
|
||||
disabled={authBusyKey === `oauth:${selectedProvider.id}:${index}`}
|
||||
>
|
||||
Connect
|
||||
{t('settings.providers.page.actions.connect')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -907,7 +909,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
{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 ?? '')}>Copy Code</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>{t('settings.providers.page.actions.copyCode')}</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -915,8 +917,8 @@ export const ProvidersPage: React.FC = () => {
|
||||
<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 ?? '')}>Open</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>Copy</Button>
|
||||
<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>
|
||||
)}
|
||||
@@ -931,7 +933,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
[codeKey]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Paste authorization code"
|
||||
placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
@@ -940,7 +942,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
onClick={() => handleOAuthComplete(selectedProvider.id, index)}
|
||||
disabled={authBusyKey === `oauth-complete:${selectedProvider.id}:${index}`}
|
||||
>
|
||||
{authBusyKey === `oauth-complete:${selectedProvider.id}:${index}` ? 'Saving...' : 'Complete'}
|
||||
{authBusyKey === `oauth-complete:${selectedProvider.id}:${index}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.complete')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -957,7 +959,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
{/* Connection Details */}
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Connection Details</h3>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.providers.page.connectionDetails.title')}</h3>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
@@ -965,15 +967,16 @@ export const ProvidersPage: React.FC = () => {
|
||||
<div className="flex min-w-0 flex-col">
|
||||
{selectedSources && (selectedSources.auth.exists || selectedSources.user.exists || selectedSources.project.exists || selectedSources.custom?.exists) ? (
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
Configured in: {[
|
||||
selectedSources.auth.exists ? 'auth credentials' : null,
|
||||
selectedSources.user.exists ? 'user config' : null,
|
||||
selectedSources.project.exists ? 'project config' : null,
|
||||
selectedSources.custom?.exists ? 'custom config' : null,
|
||||
{t('settings.providers.page.connectionDetails.configuredIn')}{' '}
|
||||
{[
|
||||
selectedSources.auth.exists ? t('settings.providers.page.connectionDetails.source.authCredentials') : null,
|
||||
selectedSources.user.exists ? t('settings.providers.page.connectionDetails.source.userConfig') : null,
|
||||
selectedSources.project.exists ? t('settings.providers.page.connectionDetails.source.projectConfig') : null,
|
||||
selectedSources.custom?.exists ? t('settings.providers.page.connectionDetails.source.customConfig') : null,
|
||||
].filter(Boolean).join(', ')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="typography-meta text-muted-foreground">No active configuration source</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.providers.page.connectionDetails.noActiveSource')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -984,7 +987,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
onClick={() => handleDisconnectProvider(selectedProvider.id)}
|
||||
disabled={authBusyKey === `disconnect:${selectedProvider.id}`}
|
||||
>
|
||||
{authBusyKey === `disconnect:${selectedProvider.id}` ? 'Disconnecting...' : 'Disconnect'}
|
||||
{authBusyKey === `disconnect:${selectedProvider.id}` ? t('settings.providers.page.actions.disconnecting') : t('settings.providers.page.actions.disconnect')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -994,7 +997,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1 flex items-center justify-between gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Available Models
|
||||
{t('settings.providers.page.models.title')}
|
||||
{providerModels.length > 0 && (
|
||||
<span className="ml-1.5 typography-micro text-muted-foreground font-normal">
|
||||
({providerModels.length})
|
||||
@@ -1013,7 +1016,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
hideAllModels(selectedProvider.id, allIds);
|
||||
}}
|
||||
>
|
||||
Hide all
|
||||
{t('settings.providers.page.actions.hideAll')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -1021,7 +1024,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
className="!font-normal"
|
||||
onClick={() => showAllModels(selectedProvider.id)}
|
||||
>
|
||||
Show all
|
||||
{t('settings.providers.page.actions.showAll')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1032,13 +1035,13 @@ export const ProvidersPage: React.FC = () => {
|
||||
<Input
|
||||
value={modelQuery}
|
||||
onChange={(event) => setModelQuery(event.target.value)}
|
||||
placeholder="Filter models..."
|
||||
placeholder={t('settings.providers.page.models.filterPlaceholder')}
|
||||
className="h-7 pl-8 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filteredModels.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground py-4 text-center">No models match this filter.</p>
|
||||
<p className="typography-meta text-muted-foreground py-4 text-center">{t('settings.providers.page.models.noModelsMatchFilter')}</p>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--surface-subtle)]">
|
||||
{filteredModels.map((model) => {
|
||||
@@ -1053,9 +1056,9 @@ export const ProvidersPage: React.FC = () => {
|
||||
const outputTokens = formatTokens(metadata?.limit?.output);
|
||||
|
||||
const capabilityIcons: Array<{ key: string; icon: typeof RiToolsLine; label: string }> = [];
|
||||
if (metadata?.tool_call) capabilityIcons.push({ key: 'tools', icon: RiToolsLine, label: 'Tool calling' });
|
||||
if (metadata?.reasoning) capabilityIcons.push({ key: 'reasoning', icon: RiBrainAi3Line, label: 'Reasoning' });
|
||||
if (metadata?.attachment) capabilityIcons.push({ key: 'image', icon: RiFileImageLine, label: 'Image input' });
|
||||
if (metadata?.tool_call) capabilityIcons.push({ key: 'tools', icon: RiToolsLine, label: t('settings.providers.page.models.capability.toolCalling') });
|
||||
if (metadata?.reasoning) capabilityIcons.push({ key: 'reasoning', icon: RiBrainAi3Line, label: t('settings.providers.page.models.capability.reasoning') });
|
||||
if (metadata?.attachment) capabilityIcons.push({ key: 'image', icon: RiFileImageLine, label: t('settings.providers.page.models.capability.imageInput') });
|
||||
|
||||
return (
|
||||
<div key={modelId} className="py-1.5">
|
||||
@@ -1071,9 +1074,9 @@ export const ProvidersPage: React.FC = () => {
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{(contextTokens || outputTokens) && (
|
||||
<span className="typography-micro text-muted-foreground flex-shrink-0 bg-[var(--surface-muted)] px-1.5 py-0.5 rounded">
|
||||
{contextTokens ? `${contextTokens} ctx` : ''}
|
||||
{contextTokens ? `${contextTokens} ${t('settings.providers.page.models.tokenBadge.context')}` : ''}
|
||||
{contextTokens && outputTokens ? ' · ' : ''}
|
||||
{outputTokens ? `${outputTokens} out` : ''}
|
||||
{outputTokens ? `${outputTokens} ${t('settings.providers.page.models.tokenBadge.output')}` : ''}
|
||||
</span>
|
||||
)}
|
||||
{capabilityIcons.length > 0 && (
|
||||
@@ -1094,8 +1097,8 @@ export const ProvidersPage: React.FC = () => {
|
||||
type="button"
|
||||
onClick={() => toggleHiddenModel(selectedProvider.id, modelId)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-[var(--interactive-hover)]/50"
|
||||
title={isHidden ? 'Show model in selectors' : 'Hide model from selectors'}
|
||||
aria-label={isHidden ? 'Show model' : 'Hide model'}
|
||||
title={isHidden ? t('settings.providers.page.models.actions.showModelInSelectors') : t('settings.providers.page.models.actions.hideModelFromSelectors')}
|
||||
aria-label={isHidden ? t('settings.providers.page.models.actions.showModel') : t('settings.providers.page.models.actions.hideModel')}
|
||||
>
|
||||
{isHidden ? <RiEyeOffLine className="h-3.5 w-3.5" /> : <RiEyeLine className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { RiAddLine, RiStackLine } from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const ADD_PROVIDER_ID = '__add_provider__';
|
||||
|
||||
@@ -36,6 +37,7 @@ interface ProvidersSidebarProps {
|
||||
}
|
||||
|
||||
export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
const selectedProviderId = useConfigStore((state) => state.selectedProviderId);
|
||||
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
|
||||
@@ -106,10 +108,10 @@ export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">Providers</h2>
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.providers.sidebar.title')}</h2>
|
||||
<SettingsProjectSelector className="mb-3" />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">Total {providers.length}</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.providers.sidebar.total', { count: providers.length })}</span>
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
|
||||
@@ -117,8 +119,8 @@ export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect
|
||||
setSelectedProvider(ADD_PROVIDER_ID);
|
||||
onItemSelect?.();
|
||||
}}
|
||||
aria-label="Connect provider"
|
||||
title="Connect provider"
|
||||
aria-label={t('settings.providers.sidebar.actions.connectProviderAria')}
|
||||
title={t('settings.providers.sidebar.actions.connectProviderTitle')}
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -129,15 +131,15 @@ export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect
|
||||
{providers.length === 0 ? (
|
||||
<div className="py-12 px-4 text-center text-muted-foreground">
|
||||
<RiStackLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
||||
<p className="typography-ui-label font-medium">No providers found</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Check your OpenCode configuration</p>
|
||||
<p className="typography-ui-label font-medium">{t('settings.providers.sidebar.empty.title')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.providers.sidebar.empty.description')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{userProviders.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
User Providers
|
||||
{t('settings.providers.sidebar.section.userProviders')}
|
||||
</div>
|
||||
{userProviders.map((provider) => (
|
||||
<ProviderListItem
|
||||
@@ -156,7 +158,7 @@ export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect
|
||||
{projectProviders.length > 0 && (
|
||||
<>
|
||||
<div className={cn('px-2 pb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground', userProviders.length > 0 ? 'pt-3' : 'pt-2')}>
|
||||
Project Providers
|
||||
{t('settings.providers.sidebar.section.projectProviders')}
|
||||
</div>
|
||||
{projectProviders.map((provider) => (
|
||||
<ProviderListItem
|
||||
|
||||
@@ -41,6 +41,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { toast } from '@/components/ui';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { useI18n, type I18nKey } from '@/lib/i18n';
|
||||
import {
|
||||
desktopSshLogsClear,
|
||||
desktopSshLogs,
|
||||
@@ -58,34 +59,34 @@ const isPortInUseError = (error: unknown): boolean => {
|
||||
return message.includes('address already in use') || message.includes('eaddrinuse') || message.includes('port already in use');
|
||||
};
|
||||
|
||||
const phaseLabel = (phase?: string): string => {
|
||||
const phaseLabelKey = (phase?: string): I18nKey => {
|
||||
switch (phase) {
|
||||
case 'config_resolved':
|
||||
return 'Resolving configuration';
|
||||
return 'settings.remoteInstances.page.phase.resolvingConfiguration';
|
||||
case 'auth_check':
|
||||
return 'Checking auth';
|
||||
return 'settings.remoteInstances.page.phase.checkingAuth';
|
||||
case 'master_connecting':
|
||||
return 'Establishing SSH';
|
||||
return 'settings.remoteInstances.page.phase.establishingSsh';
|
||||
case 'remote_probe':
|
||||
return 'Probing remote';
|
||||
return 'settings.remoteInstances.page.phase.probingRemote';
|
||||
case 'installing':
|
||||
return 'Installing OpenChamber';
|
||||
return 'settings.remoteInstances.page.phase.installingOpenChamber';
|
||||
case 'updating':
|
||||
return 'Updating OpenChamber';
|
||||
return 'settings.remoteInstances.page.phase.updatingOpenChamber';
|
||||
case 'server_detecting':
|
||||
return 'Detecting server';
|
||||
return 'settings.remoteInstances.page.phase.detectingServer';
|
||||
case 'server_starting':
|
||||
return 'Starting server';
|
||||
return 'settings.remoteInstances.page.phase.startingServer';
|
||||
case 'forwarding':
|
||||
return 'Forwarding ports';
|
||||
return 'settings.remoteInstances.page.phase.forwardingPorts';
|
||||
case 'ready':
|
||||
return 'Ready';
|
||||
return 'settings.remoteInstances.sidebar.phase.ready';
|
||||
case 'degraded':
|
||||
return 'Reconnecting';
|
||||
return 'settings.remoteInstances.page.phase.reconnecting';
|
||||
case 'error':
|
||||
return 'Error';
|
||||
return 'settings.remoteInstances.sidebar.phase.error';
|
||||
default:
|
||||
return 'Idle';
|
||||
return 'settings.remoteInstances.sidebar.phase.idle';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -161,14 +162,14 @@ const HintLabel: React.FC<{ label: string; hint: React.ReactNode }> = ({ label,
|
||||
);
|
||||
};
|
||||
|
||||
const forwardTypeDescription = (type: DesktopSshPortForwardType): string => {
|
||||
const forwardTypeDescriptionKey = (type: DesktopSshPortForwardType): I18nKey => {
|
||||
switch (type) {
|
||||
case 'remote':
|
||||
return 'Remote (-R): expose a port on the remote machine and send that traffic back to this laptop.';
|
||||
return 'settings.remoteInstances.page.forwardTypeDescription.remote';
|
||||
case 'dynamic':
|
||||
return 'Dynamic (-D): create a local SOCKS5 proxy on this laptop (for apps that support SOCKS proxy settings).';
|
||||
return 'settings.remoteInstances.page.forwardTypeDescription.dynamic';
|
||||
default:
|
||||
return 'Local (-L): open a port on this laptop and send it to a remote host:port over SSH (use this to access remote services locally).';
|
||||
return 'settings.remoteInstances.page.forwardTypeDescription.local';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -254,6 +255,7 @@ const normalizeForSave = (instance: DesktopSshInstance): DesktopSshInstance => {
|
||||
};
|
||||
|
||||
export const RemoteInstancesPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const instances = useDesktopSshStore((state) => state.instances);
|
||||
const statusesById = useDesktopSshStore((state) => state.statusesById);
|
||||
const importCandidates = useDesktopSshStore((state) => state.importCandidates);
|
||||
@@ -379,13 +381,13 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
const normalized = normalizeForSave(draft);
|
||||
|
||||
if (!normalized.sshCommand.trim()) {
|
||||
toast.error('SSH command is required');
|
||||
toast.error(t('settings.remoteInstances.page.toast.sshCommandRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalized.localForward.bindHost === '0.0.0.0') {
|
||||
const allow = window.confirm(
|
||||
'Binding local forwards to 0.0.0.0 makes the forwarded port reachable from other devices on your network. Continue?',
|
||||
t('settings.remoteInstances.page.confirm.bindAllInterfaces'),
|
||||
);
|
||||
if (!allow) {
|
||||
return;
|
||||
@@ -397,7 +399,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
normalized.auth.sshPassword.value?.trim() &&
|
||||
normalized.auth.sshPassword.store !== 'settings'
|
||||
) {
|
||||
const store = window.confirm('Store SSH password in settings.json as plaintext?');
|
||||
const store = window.confirm(t('settings.remoteInstances.page.confirm.storeSshPasswordPlaintext'));
|
||||
normalized.auth.sshPassword.store = store ? 'settings' : 'never';
|
||||
if (!store) {
|
||||
normalized.auth.sshPassword.value = undefined;
|
||||
@@ -409,7 +411,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
normalized.auth.openchamberPassword.value?.trim() &&
|
||||
normalized.auth.openchamberPassword.store !== 'settings'
|
||||
) {
|
||||
const store = window.confirm('Store OpenChamber UI password in settings.json as plaintext?');
|
||||
const store = window.confirm(t('settings.remoteInstances.page.confirm.storeUiPasswordPlaintext'));
|
||||
normalized.auth.openchamberPassword.store = store ? 'settings' : 'never';
|
||||
if (!store) {
|
||||
normalized.auth.openchamberPassword.value = undefined;
|
||||
@@ -418,13 +420,13 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
|
||||
try {
|
||||
await upsertInstance(normalized);
|
||||
toast.success('SSH instance saved');
|
||||
toast.success(t('settings.remoteInstances.page.toast.instanceSaved'));
|
||||
} catch (error) {
|
||||
toast.error('Failed to save SSH instance', {
|
||||
toast.error(t('settings.remoteInstances.page.toast.saveFailed'), {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}, [draft, upsertInstance]);
|
||||
}, [draft, t, upsertInstance]);
|
||||
|
||||
const createImportedInstance = React.useCallback(
|
||||
async (host: string, destination: string): Promise<boolean> => {
|
||||
@@ -432,10 +434,10 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
try {
|
||||
await createFromCommand(id, `ssh ${destination}`, host);
|
||||
setSelectedId(id);
|
||||
toast.success('SSH instance created');
|
||||
toast.success(t('settings.remoteInstances.page.toast.instanceCreated'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error('Failed to create SSH instance', {
|
||||
toast.error(t('settings.remoteInstances.sidebar.toast.createFailed'), {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return false;
|
||||
@@ -471,7 +473,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
if (!destination) {
|
||||
toast.error('Destination is required');
|
||||
toast.error(t('settings.remoteInstances.page.toast.destinationRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -485,7 +487,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
} finally {
|
||||
setPatternCreating(false);
|
||||
}
|
||||
}, [createImportedInstance, patternDestination, patternHost]);
|
||||
}, [createImportedInstance, patternDestination, patternHost, t]);
|
||||
|
||||
const connectWithPortRecovery = React.useCallback(async () => {
|
||||
if (!selectedInstance) return;
|
||||
@@ -497,7 +499,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const allow = window.confirm('Local port is already in use. Pick a random free local port and retry?');
|
||||
const allow = window.confirm(t('settings.remoteInstances.sidebar.confirm.localPortInUseRetry'));
|
||||
if (!allow) {
|
||||
throw error;
|
||||
}
|
||||
@@ -512,9 +514,9 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
|
||||
await upsertInstance(nextInstance);
|
||||
await connect(nextInstance.id);
|
||||
toast.success('Retried with a random local port');
|
||||
toast.success(t('settings.remoteInstances.sidebar.toast.retriedWithRandomPort'));
|
||||
}
|
||||
}, [connect, selectedInstance, upsertInstance]);
|
||||
}, [connect, selectedInstance, t, upsertInstance]);
|
||||
|
||||
const readLogsForInstance = React.useCallback(async (id: string) => {
|
||||
const lines = await desktopSshLogs(id, 600);
|
||||
@@ -576,15 +578,15 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
|
||||
const handleCopyAllLogs = React.useCallback(() => {
|
||||
if (!logLinesText.trim()) {
|
||||
toast.error('No logs to copy');
|
||||
toast.error(t('settings.remoteInstances.page.toast.noLogsToCopy'));
|
||||
return;
|
||||
}
|
||||
void copyTextToClipboard(logLinesText).then((result) => {
|
||||
if (result.ok) {
|
||||
toast.success('Logs copied');
|
||||
toast.success(t('settings.remoteInstances.page.toast.logsCopied'));
|
||||
}
|
||||
});
|
||||
}, [logLinesText]);
|
||||
}, [logLinesText, t]);
|
||||
|
||||
const handleClearLogs = React.useCallback(async () => {
|
||||
if (!draft) {
|
||||
@@ -593,28 +595,28 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
try {
|
||||
await desktopSshLogsClear(draft.id);
|
||||
setLogDialogLines([]);
|
||||
toast.success('Logs cleared');
|
||||
toast.success(t('settings.remoteInstances.page.toast.logsCleared'));
|
||||
} catch (error) {
|
||||
toast.error('Failed to clear logs', {
|
||||
toast.error(t('settings.remoteInstances.page.toast.clearLogsFailed'), {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}, [draft]);
|
||||
}, [draft, t]);
|
||||
|
||||
const handleOpenCurrentInstance = React.useCallback(async () => {
|
||||
if (!status?.localUrl) {
|
||||
toast.error('Instance URL is not available yet');
|
||||
toast.error(t('settings.remoteInstances.page.toast.instanceUrlUnavailable'));
|
||||
return;
|
||||
}
|
||||
|
||||
const target = status.localUrl.trim();
|
||||
if (!target) {
|
||||
toast.error('Instance URL is not available yet');
|
||||
toast.error(t('settings.remoteInstances.page.toast.instanceUrlUnavailable'));
|
||||
return;
|
||||
}
|
||||
|
||||
navigateToUrl(target);
|
||||
}, [status?.localUrl]);
|
||||
}, [status?.localUrl, t]);
|
||||
|
||||
const handlePrimaryConnectionAction = React.useCallback(() => {
|
||||
if (!draft) {
|
||||
@@ -625,15 +627,19 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
const operation = canDisconnect ? disconnect(draft.id) : connectWithPortRecovery();
|
||||
void operation
|
||||
.catch((error) => {
|
||||
const actionLabel = canDisconnect ? (isReady ? 'disconnect' : 'cancel connection') : 'connect';
|
||||
toast.error(`Failed to ${actionLabel}`, {
|
||||
const key = canDisconnect
|
||||
? (isReady
|
||||
? 'settings.remoteInstances.page.toast.disconnectFailed'
|
||||
: 'settings.remoteInstances.page.toast.cancelConnectionFailed')
|
||||
: 'settings.remoteInstances.page.toast.connectFailed';
|
||||
toast.error(t(key), {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsPrimaryActionPending(false);
|
||||
});
|
||||
}, [canDisconnect, connectWithPortRecovery, disconnect, draft, isReady]);
|
||||
}, [canDisconnect, connectWithPortRecovery, disconnect, draft, isReady, t]);
|
||||
|
||||
const handleRetryAction = React.useCallback(() => {
|
||||
if (!draft) {
|
||||
@@ -651,22 +657,22 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
|
||||
void operation
|
||||
.catch((error) => {
|
||||
toast.error('Retry failed', {
|
||||
toast.error(t('settings.remoteInstances.page.toast.retryFailed'), {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsRetryPending(false);
|
||||
});
|
||||
}, [connectWithPortRecovery, disconnect, draft, isConnecting, isReconnecting, retry]);
|
||||
}, [connectWithPortRecovery, disconnect, draft, isConnecting, isReconnecting, retry, t]);
|
||||
|
||||
const retryButtonLabel = isConnecting
|
||||
? 'Connecting...'
|
||||
? t('settings.remoteInstances.page.actions.connecting')
|
||||
: isReconnecting
|
||||
? reconnectAppearsStuck
|
||||
? 'Reconnect now'
|
||||
: 'Reconnecting...'
|
||||
: 'Retry';
|
||||
? t('settings.remoteInstances.page.actions.reconnectNow')
|
||||
: t('settings.remoteInstances.page.actions.reconnecting')
|
||||
: t('settings.remoteInstances.sidebar.actions.retry');
|
||||
|
||||
const canRetry =
|
||||
!isPrimaryActionPending &&
|
||||
@@ -674,30 +680,34 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
(statusPhase === 'error' || statusPhase === 'idle' || !statusPhase || (isReconnecting && reconnectAppearsStuck)) &&
|
||||
!isConnecting;
|
||||
|
||||
const primaryButtonLabel = isReady ? 'Disconnect' : canDisconnect ? 'Cancel' : 'Connect';
|
||||
const primaryButtonLabel = isReady
|
||||
? t('settings.remoteInstances.sidebar.actions.disconnect')
|
||||
: canDisconnect
|
||||
? t('settings.remoteInstances.page.actions.cancel')
|
||||
: t('settings.remoteInstances.sidebar.actions.connect');
|
||||
|
||||
if (!draft) {
|
||||
return (
|
||||
<SettingsPageLayout>
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Remote Instances</h3>
|
||||
<p className="typography-meta text-muted-foreground">Manage SSH-backed OpenChamber instances.</p>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.title')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.description')}</p>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-3">
|
||||
<p className="typography-meta text-muted-foreground">Select an instance from the sidebar or import one from SSH config.</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.empty.selectInstance')}</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Import from SSH config</h3>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.import.sectionTitle')}</h3>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
{isImportsLoading ? (
|
||||
<p className="typography-meta text-muted-foreground">Loading SSH hosts...</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
|
||||
) : importCandidates.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">No SSH config hosts found.</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.noneFound')}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{importCandidates.map((candidate) => (
|
||||
@@ -705,7 +715,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
<div className="min-w-0">
|
||||
<div className="typography-ui-label text-foreground truncate">
|
||||
{candidate.host}
|
||||
{candidate.pattern ? ' (pattern)' : ''}
|
||||
{candidate.pattern ? ` ${t('settings.remoteInstances.page.import.patternSuffix')}` : ''}
|
||||
</div>
|
||||
<div className="typography-micro text-muted-foreground">{candidate.source} config</div>
|
||||
</div>
|
||||
@@ -716,7 +726,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
className="!font-normal"
|
||||
onClick={() => void handleImportCandidate(candidate.host, candidate.pattern)}
|
||||
>
|
||||
Create
|
||||
{t('settings.remoteInstances.page.actions.create')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
@@ -735,9 +745,9 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create from wildcard pattern</DialogTitle>
|
||||
<DialogTitle>{t('settings.remoteInstances.page.patternDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{patternHost ? `${patternHost} requires a concrete destination.` : 'Enter destination.'}
|
||||
{patternHost ? t('settings.remoteInstances.page.patternDialog.descriptionWithHost', { host: patternHost }) : t('settings.remoteInstances.page.patternDialog.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
@@ -750,15 +760,15 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
<Input
|
||||
value={patternDestination}
|
||||
onChange={(event) => setPatternDestination(event.target.value)}
|
||||
placeholder="user@host"
|
||||
placeholder={t('settings.remoteInstances.page.patternDialog.destinationPlaceholder')}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={closePatternDialog} disabled={patternCreating}>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" size="xs" className="!font-normal" disabled={patternCreating}>
|
||||
Create
|
||||
{t('settings.remoteInstances.page.actions.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -777,16 +787,16 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
<h2 className="typography-ui-header font-semibold text-foreground truncate">{instanceTitle}</h2>
|
||||
<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>{phaseLabel(statusPhase)}</span>
|
||||
<span>{t(phaseLabelKey(statusPhase))}</span>
|
||||
{status?.localUrl ? <span className="font-mono text-foreground/80">{status.localUrl}</span> : null}
|
||||
{reconnectAppearsStuck ? <span>reconnect stale</span> : null}
|
||||
{reconnectAppearsStuck ? <span>{t('settings.remoteInstances.page.status.reconnectStale')}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Actions</h3>
|
||||
<p className="typography-meta text-muted-foreground">Connect, inspect logs, and manage this instance.</p>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.actions')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.actionsDescription')}</p>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -822,7 +832,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<RiTerminalWindowLine className="h-3.5 w-3.5" />
|
||||
Logs
|
||||
{t('settings.remoteInstances.page.actions.logs')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -830,27 +840,27 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
size="xs"
|
||||
className="!font-normal text-[var(--status-error)] border-[var(--status-error)]/30 hover:text-[var(--status-error)]"
|
||||
onClick={() => {
|
||||
const ok = window.confirm('Remove this SSH instance?');
|
||||
const ok = window.confirm(t('settings.remoteInstances.page.confirm.removeInstance'));
|
||||
if (!ok) return;
|
||||
void removeInstance(draft.id)
|
||||
.then(() => {
|
||||
setSelectedId(null);
|
||||
toast.success('SSH instance removed');
|
||||
toast.success(t('settings.remoteInstances.page.toast.instanceRemoved'));
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('Failed to remove SSH instance', {
|
||||
toast.error(t('settings.remoteInstances.page.toast.removeInstanceFailed'), {
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
Remove
|
||||
{t('settings.remoteInstances.sidebar.actions.remove')}
|
||||
</Button>
|
||||
</div>
|
||||
{status?.localUrl ? (
|
||||
<div className="flex flex-wrap items-center gap-2 typography-meta text-muted-foreground">
|
||||
<span>Current local URL:</span>
|
||||
<span>{t('settings.remoteInstances.page.status.currentLocalUrl')}</span>
|
||||
<span className="font-mono text-foreground/90">{status.localUrl}</span>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -859,12 +869,12 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Instance</h3>
|
||||
<p className="typography-meta text-muted-foreground">Core SSH settings.</p>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.instance')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.instanceDescription')}</p>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 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">SSH command</span>
|
||||
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.sshCommand')}</span>
|
||||
<Input
|
||||
className="h-7 md:max-w-xl"
|
||||
value={draft.sshCommand}
|
||||
@@ -874,11 +884,11 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
sshCommand: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="ssh -J jump user@host"
|
||||
placeholder={t('settings.remoteInstances.page.field.sshCommandPlaceholder')}
|
||||
/>
|
||||
</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">Nickname</span>
|
||||
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.nickname')}</span>
|
||||
<Input
|
||||
className="h-7 md:max-w-sm"
|
||||
value={draft.nickname || ''}
|
||||
@@ -888,11 +898,11 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
nickname: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Production Host"
|
||||
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">Connection timeout (sec)</span>
|
||||
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.connectionTimeoutSeconds')}</span>
|
||||
<NumberInput
|
||||
containerClassName="w-fit"
|
||||
min={5}
|
||||
@@ -913,16 +923,16 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
|
||||
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Remote server</h3>
|
||||
<p className="typography-meta text-muted-foreground">How OpenChamber is discovered or started on the remote machine.</p>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.remoteServer')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.remoteServerDescription')}</p>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 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
|
||||
label="Mode"
|
||||
hint="Managed installs/updates and starts OpenChamber remotely. External assumes it is already running."
|
||||
/>
|
||||
<HintLabel
|
||||
label={t('settings.remoteInstances.page.field.mode')}
|
||||
hint={t('settings.remoteInstances.page.field.modeHint')}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={draft.remoteOpenchamber.mode}
|
||||
@@ -937,21 +947,21 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-fit min-w-[140px]">
|
||||
<SelectValue placeholder="Select mode" />
|
||||
<SelectValue placeholder={t('settings.remoteInstances.page.field.modePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="managed">Managed (auto start)</SelectItem>
|
||||
<SelectItem value="external">External (already running)</SelectItem>
|
||||
<SelectItem value="managed">{t('settings.remoteInstances.page.field.modeManaged')}</SelectItem>
|
||||
<SelectItem value="external">{t('settings.remoteInstances.page.field.modeExternal')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<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
|
||||
label="Preferred remote port"
|
||||
hint="Port OpenChamber should use on the remote host. Leave empty to let the runtime choose."
|
||||
/>
|
||||
<HintLabel
|
||||
label={t('settings.remoteInstances.page.field.preferredRemotePort')}
|
||||
hint={t('settings.remoteInstances.page.field.preferredRemotePortHint')}
|
||||
/>
|
||||
</div>
|
||||
<NumberInput
|
||||
containerClassName="w-fit"
|
||||
@@ -978,7 +988,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
},
|
||||
}));
|
||||
}}
|
||||
emptyLabel="Auto"
|
||||
emptyLabel={t('settings.remoteInstances.page.field.auto')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -986,8 +996,8 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
<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
|
||||
label="Install method"
|
||||
hint="How OpenChamber gets installed/updated remotely when mode is Managed."
|
||||
label={t('settings.remoteInstances.page.field.installMethod')}
|
||||
hint={t('settings.remoteInstances.page.field.installMethodHint')}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
@@ -1006,13 +1016,13 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-fit min-w-[140px]">
|
||||
<SelectValue placeholder="Select install method" />
|
||||
<SelectValue placeholder={t('settings.remoteInstances.page.field.selectInstallMethodPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="bun">bun</SelectItem>
|
||||
<SelectItem value="npm">npm</SelectItem>
|
||||
<SelectItem value="download_release">download release</SelectItem>
|
||||
<SelectItem value="upload_bundle">upload bundle</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>
|
||||
@@ -1022,8 +1032,8 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
<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
|
||||
label="Keep server running"
|
||||
hint="If enabled, OpenChamber daemon is left running remotely when you disconnect."
|
||||
label={t('settings.remoteInstances.page.field.keepServerRunning')}
|
||||
hint={t('settings.remoteInstances.page.field.keepServerRunningHint')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-2 md:max-w-xs">
|
||||
@@ -1047,23 +1057,23 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
|
||||
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Main tunnel</h3>
|
||||
<p className="typography-meta text-muted-foreground">Primary local URL that points to the remote OpenChamber server.</p>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.mainTunnel')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.mainTunnelDescription')}</p>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 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
|
||||
label="Bind host"
|
||||
hint="Network interface for the main local URL. Use 127.0.0.1/localhost for local-only access."
|
||||
/>
|
||||
<HintLabel
|
||||
label={t('settings.remoteInstances.page.field.bindHost')}
|
||||
hint={t('settings.remoteInstances.page.field.bindHostHint')}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={draft.localForward.bindHost}
|
||||
onValueChange={(value) => {
|
||||
if (value === '0.0.0.0') {
|
||||
const allow = window.confirm(
|
||||
'Binding to 0.0.0.0 exposes forwarded ports to your local network. Continue?',
|
||||
t('settings.remoteInstances.page.confirm.bindAllInterfaces'),
|
||||
);
|
||||
if (!allow) return;
|
||||
}
|
||||
@@ -1077,7 +1087,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-fit min-w-[140px]">
|
||||
<SelectValue placeholder="Select bind host" />
|
||||
<SelectValue placeholder={t('settings.remoteInstances.page.field.selectBindHostPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="127.0.0.1">127.0.0.1</SelectItem>
|
||||
@@ -1089,10 +1099,10 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
|
||||
<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
|
||||
label="Preferred local port"
|
||||
hint="Preferred local port for the main OpenChamber tunnel. Leave empty for auto-select."
|
||||
/>
|
||||
<HintLabel
|
||||
label={t('settings.remoteInstances.page.field.preferredLocalPort')}
|
||||
hint={t('settings.remoteInstances.page.field.preferredLocalPortHint')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-2 md:max-w-sm">
|
||||
<NumberInput
|
||||
@@ -1120,14 +1130,14 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
},
|
||||
}));
|
||||
}}
|
||||
emptyLabel="Auto"
|
||||
emptyLabel={t('settings.remoteInstances.page.field.auto')}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal h-7 w-7 px-0"
|
||||
title="Pick random port"
|
||||
title={t('settings.remoteInstances.page.actions.pickRandomPort')}
|
||||
onClick={() =>
|
||||
updateDraft((current) => ({
|
||||
...current,
|
||||
@@ -1147,12 +1157,12 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
|
||||
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Authentication</h3>
|
||||
<p className="typography-meta text-muted-foreground">Optional credentials for SSH and remote UI.</p>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.authentication')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.authenticationDescription')}</p>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 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">SSH password (optional)</span>
|
||||
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.sshPasswordOptional')}</span>
|
||||
<Input
|
||||
className="h-7 md:max-w-sm"
|
||||
type="password"
|
||||
@@ -1170,12 +1180,12 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
},
|
||||
}))
|
||||
}
|
||||
placeholder="Password or key passphrase"
|
||||
placeholder={t('settings.remoteInstances.page.field.sshPasswordPlaceholder')}
|
||||
/>
|
||||
</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">OpenChamber UI password (optional)</span>
|
||||
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.uiPasswordOptional')}</span>
|
||||
<Input
|
||||
className="h-7 md:max-w-sm"
|
||||
type="password"
|
||||
@@ -1193,7 +1203,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
},
|
||||
}))
|
||||
}
|
||||
placeholder="Protect remote UI with password"
|
||||
placeholder={t('settings.remoteInstances.page.field.uiPasswordPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1201,12 +1211,12 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
|
||||
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Port Forwards</h3>
|
||||
<p className="typography-meta text-muted-foreground">Optional extra SSH forwards in addition to the primary OpenChamber tunnel.</p>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.portForwards')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.portForwardsDescription')}</p>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-2">
|
||||
{draft.portForwards.length === 0 ? (
|
||||
<p className="typography-micro text-muted-foreground/80">No extra forwards configured yet.</p>
|
||||
<p className="typography-micro text-muted-foreground/80">{t('settings.remoteInstances.page.empty.noExtraForwards')}</p>
|
||||
) : null}
|
||||
|
||||
{draft.portForwards.map((forward, index) => {
|
||||
@@ -1238,7 +1248,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
|
||||
const isForwardOpen = Boolean(expandedForwards[forward.id]);
|
||||
|
||||
const typeLabel = forward.type === 'local' ? 'Local (-L)' : forward.type === 'remote' ? 'Remote (-R)' : 'Dynamic (-D)';
|
||||
const typeLabel = forward.type === 'local' ? t('settings.remoteInstances.page.forwardType.local') : forward.type === 'remote' ? t('settings.remoteInstances.page.forwardType.remote') : t('settings.remoteInstances.page.forwardType.dynamic');
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
@@ -1261,7 +1271,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={forward.enabled} onCheckedChange={(checked) => updateForward((item) => ({ ...item, enabled: checked }))} aria-label="Enable forward" />
|
||||
<Switch checked={forward.enabled} onCheckedChange={(checked) => updateForward((item) => ({ ...item, enabled: checked }))} aria-label={t('settings.remoteInstances.page.actions.enableForwardAria')} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -1280,12 +1290,12 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
</div>
|
||||
<CollapsibleContent className="pt-2">
|
||||
<div className="space-y-0 pb-2">
|
||||
<p className="typography-meta text-muted-foreground mb-3">{forwardTypeDescription(forward.type)}</p>
|
||||
<p className="typography-meta text-muted-foreground mb-3">{t(forwardTypeDescriptionKey(forward.type))}</p>
|
||||
<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
|
||||
label="Forward type"
|
||||
hint="Local (-L): laptop -> remote service. Remote (-R): remote machine -> this laptop. Dynamic (-D): local SOCKS5 proxy."
|
||||
label={t('settings.remoteInstances.page.field.forwardType')}
|
||||
hint={t('settings.remoteInstances.page.field.forwardTypeHint')}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
@@ -1298,12 +1308,12 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-fit min-w-[140px]">
|
||||
<SelectValue placeholder="Type" />
|
||||
<SelectValue placeholder={t('settings.remoteInstances.page.field.typePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="local">Local (-L)</SelectItem>
|
||||
<SelectItem value="remote">Remote (-R)</SelectItem>
|
||||
<SelectItem value="dynamic">Dynamic (-D)</SelectItem>
|
||||
<SelectItem value="local">{t('settings.remoteInstances.page.forwardType.local')}</SelectItem>
|
||||
<SelectItem value="remote">{t('settings.remoteInstances.page.forwardType.remote')}</SelectItem>
|
||||
<SelectItem value="dynamic">{t('settings.remoteInstances.page.forwardType.dynamic')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1322,7 +1332,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
localHost: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="127.0.0.1"
|
||||
placeholder={t('settings.remoteInstances.page.field.localHostPlaceholder')}
|
||||
/>
|
||||
<span className="text-muted-foreground">:</span>
|
||||
<NumberInput
|
||||
@@ -1344,7 +1354,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
localPort: undefined,
|
||||
}));
|
||||
}}
|
||||
emptyLabel="Auto"
|
||||
emptyLabel={t('settings.remoteInstances.page.field.auto')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1364,7 +1374,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
remoteHost: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="127.0.0.1"
|
||||
placeholder={t('settings.remoteInstances.page.field.remoteHostPlaceholder')}
|
||||
/>
|
||||
<span className="text-muted-foreground">:</span>
|
||||
<NumberInput
|
||||
@@ -1386,7 +1396,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
remotePort: undefined,
|
||||
}));
|
||||
}}
|
||||
emptyLabel="Auto"
|
||||
emptyLabel={t('settings.remoteInstances.page.field.auto')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1398,27 +1408,27 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
<>
|
||||
<RiComputerLine className="h-3.5 w-3.5" />
|
||||
<span className="font-mono text-foreground">{localEndpoint}</span>
|
||||
<span>(local SOCKS5)</span>
|
||||
<span>{t('settings.remoteInstances.page.preview.localSocks5')}</span>
|
||||
</>
|
||||
) : forward.type === 'remote' ? (
|
||||
<>
|
||||
<RiServerLine className="h-3.5 w-3.5" />
|
||||
<span className="font-mono text-foreground">{remoteEndpoint}</span>
|
||||
<span>(remote)</span>
|
||||
<span>{t('settings.remoteInstances.page.preview.remote')}</span>
|
||||
<RiArrowRightLine className="h-3.5 w-3.5" />
|
||||
<RiComputerLine className="h-3.5 w-3.5" />
|
||||
<span className="font-mono text-foreground">{localEndpoint}</span>
|
||||
<span>(local)</span>
|
||||
<span>{t('settings.remoteInstances.page.preview.local')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiComputerLine className="h-3.5 w-3.5" />
|
||||
<span className="font-mono text-foreground">{localEndpoint}</span>
|
||||
<span>(local)</span>
|
||||
<span>{t('settings.remoteInstances.page.preview.local')}</span>
|
||||
<RiArrowRightLine className="h-3.5 w-3.5" />
|
||||
<RiServerLine className="h-3.5 w-3.5" />
|
||||
<span className="font-mono text-foreground">{remoteEndpoint}</span>
|
||||
<span>(remote)</span>
|
||||
<span>{t('settings.remoteInstances.page.preview.remote')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1432,13 +1442,13 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
onClick={() => {
|
||||
void openExternalUrl(localEndpointUrl).then((opened) => {
|
||||
if (!opened) {
|
||||
toast.error('Failed to open local endpoint');
|
||||
toast.error(t('settings.remoteInstances.page.toast.openLocalEndpointFailed'));
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<RiExternalLinkLine className="h-3.5 w-3.5" />
|
||||
Open local
|
||||
{t('settings.remoteInstances.page.actions.openLocal')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1466,20 +1476,20 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
Add forward
|
||||
{t('settings.remoteInstances.page.actions.addForward')}
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Import from SSH config</h3>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.import.sectionTitle')}</h3>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
{isImportsLoading ? (
|
||||
<p className="typography-meta text-muted-foreground">Loading SSH hosts...</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
|
||||
) : importCandidates.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">No SSH hosts available.</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.noneAvailable')}</p>
|
||||
) : (
|
||||
<div>
|
||||
{importCandidates.slice(0, 8).map((candidate, index) => (
|
||||
@@ -1501,7 +1511,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
className="!font-normal"
|
||||
onClick={() => void handleImportCandidate(candidate.host, candidate.pattern)}
|
||||
>
|
||||
Import
|
||||
{t('settings.common.actions.import')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
@@ -1513,7 +1523,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
<div className="sticky bottom-0 z-10 -mx-3 sm:-mx-6 bg-[var(--surface-background)] border-t border-[var(--interactive-border)] px-3 sm:px-6 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" size="xs" className="!font-normal" onClick={() => void handleSave()} disabled={!hasChanges || isSaving}>
|
||||
Save changes
|
||||
{t('settings.common.actions.saveChanges')}
|
||||
</Button>
|
||||
{status?.localUrl ? (
|
||||
<>
|
||||
@@ -1525,13 +1535,13 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
onClick={() => {
|
||||
void copyTextToClipboard(status.localUrl || '').then((result) => {
|
||||
if (result.ok) {
|
||||
toast.success('Local URL copied');
|
||||
toast.success(t('settings.remoteInstances.page.toast.localUrlCopied'));
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<RiFileCopyLine className="h-3.5 w-3.5" />
|
||||
Copy local URL
|
||||
{t('settings.remoteInstances.page.actions.copyLocalUrl')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1543,7 +1553,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<RiExternalLinkLine className="h-3.5 w-3.5" />
|
||||
Open
|
||||
{t('settings.remoteInstances.page.actions.open')}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
@@ -1554,28 +1564,28 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
<Dialog open={logDialogOpen} onOpenChange={setLogDialogOpen}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>SSH Logs</DialogTitle>
|
||||
<DialogTitle>{t('settings.remoteInstances.page.logsDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{draft?.nickname?.trim() || draft?.sshParsed?.destination || draft?.id || 'Selected instance'}
|
||||
{draft?.nickname?.trim() || draft?.sshParsed?.destination || draft?.id || t('settings.remoteInstances.page.logsDialog.selectedInstanceFallback')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={handleCopyAllLogs} disabled={logDialogLoading || !logLinesText.trim()}>
|
||||
<RiFileCopyLine className="h-3.5 w-3.5" />
|
||||
Copy all
|
||||
{t('settings.common.actions.copyAll')}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => void handleClearLogs()} disabled={logDialogLoading}>
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
Clear
|
||||
{t('settings.common.actions.clear')}
|
||||
</Button>
|
||||
</div>
|
||||
{logDialogLoading ? (
|
||||
<div className="typography-meta text-muted-foreground">Loading logs...</div>
|
||||
<div className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.logsDialog.loading')}</div>
|
||||
) : logDialogError ? (
|
||||
<div className="typography-meta text-[var(--status-error)]">{logDialogError}</div>
|
||||
) : (
|
||||
<pre className="max-h-[55vh] overflow-auto rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-3 typography-micro text-foreground whitespace-pre-wrap break-words">
|
||||
{logDialogLines.length > 0 ? logDialogLines.join('\n') : 'No SSH logs yet.'}
|
||||
{logDialogLines.length > 0 ? logDialogLines.join('\n') : t('settings.remoteInstances.page.logsDialog.empty')}
|
||||
</pre>
|
||||
)}
|
||||
</DialogContent>
|
||||
@@ -1591,9 +1601,11 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create from wildcard pattern</DialogTitle>
|
||||
<DialogTitle>{t('settings.remoteInstances.page.patternDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{patternHost ? `${patternHost} requires a concrete destination.` : 'Enter destination.'}
|
||||
{patternHost
|
||||
? t('settings.remoteInstances.page.patternDialog.descriptionWithHost', { host: patternHost })
|
||||
: t('settings.remoteInstances.page.patternDialog.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
@@ -1606,15 +1618,15 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
<Input
|
||||
value={patternDestination}
|
||||
onChange={(event) => setPatternDestination(event.target.value)}
|
||||
placeholder="user@host"
|
||||
placeholder={t('settings.remoteInstances.page.patternDialog.destinationPlaceholder')}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={closePatternDialog} disabled={patternCreating}>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" size="xs" className="!font-normal" disabled={patternCreating}>
|
||||
Create
|
||||
{t('settings.common.actions.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { toast } from '@/components/ui';
|
||||
import type { DesktopSshInstance } from '@/lib/desktopSsh';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type RemoteInstancesSidebarProps = {
|
||||
onItemSelect?: () => void;
|
||||
@@ -28,30 +29,31 @@ const isPortInUseError = (error: unknown): boolean => {
|
||||
return message.includes('address already in use') || message.includes('eaddrinuse') || message.includes('port already in use');
|
||||
};
|
||||
|
||||
const phaseLabel = (phase?: string): string => {
|
||||
const phaseLabelKey = (phase?: string) => {
|
||||
switch (phase) {
|
||||
case 'ready':
|
||||
return 'Ready';
|
||||
return 'settings.remoteInstances.sidebar.phase.ready';
|
||||
case 'error':
|
||||
return 'Error';
|
||||
return 'settings.remoteInstances.sidebar.phase.error';
|
||||
case 'degraded':
|
||||
return 'Reconnect';
|
||||
return 'settings.remoteInstances.sidebar.phase.reconnect';
|
||||
case 'installing':
|
||||
return 'Installing';
|
||||
return 'settings.remoteInstances.sidebar.phase.installing';
|
||||
case 'updating':
|
||||
return 'Updating';
|
||||
return 'settings.remoteInstances.sidebar.phase.updating';
|
||||
case 'forwarding':
|
||||
return 'Forwarding';
|
||||
return 'settings.remoteInstances.sidebar.phase.forwarding';
|
||||
case 'server_starting':
|
||||
return 'Starting';
|
||||
return 'settings.remoteInstances.sidebar.phase.starting';
|
||||
case 'master_connecting':
|
||||
return 'Connecting';
|
||||
return 'settings.remoteInstances.sidebar.phase.connecting';
|
||||
default:
|
||||
return 'Idle';
|
||||
return 'settings.remoteInstances.sidebar.phase.idle';
|
||||
}
|
||||
};
|
||||
|
||||
export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({ onItemSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const instances = useDesktopSshStore((state) => state.instances);
|
||||
const statusesById = useDesktopSshStore((state) => state.statusesById);
|
||||
const isLoading = useDesktopSshStore((state) => state.isLoading);
|
||||
@@ -89,15 +91,15 @@ export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({
|
||||
const handleAdd = React.useCallback(async () => {
|
||||
const id = makeId();
|
||||
try {
|
||||
await createFromCommand(id, 'ssh user@example.com', 'New SSH Instance');
|
||||
await createFromCommand(id, 'ssh user@example.com', t('settings.remoteInstances.sidebar.newSshInstanceName'));
|
||||
setSelectedId(id);
|
||||
onItemSelect?.();
|
||||
} catch (error) {
|
||||
toast.error('Failed to create SSH instance', {
|
||||
toast.error(t('settings.remoteInstances.sidebar.toast.createFailed'), {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}, [createFromCommand, onItemSelect, setSelectedId]);
|
||||
}, [createFromCommand, onItemSelect, setSelectedId, t]);
|
||||
|
||||
const connectWithPortRecovery = React.useCallback(async (instance: DesktopSshInstance) => {
|
||||
try {
|
||||
@@ -108,7 +110,7 @@ export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({
|
||||
throw error;
|
||||
}
|
||||
|
||||
const allow = window.confirm('Local port is already in use. Pick a random free local port and retry?');
|
||||
const allow = window.confirm(t('settings.remoteInstances.sidebar.confirm.localPortInUseRetry'));
|
||||
if (!allow) {
|
||||
throw error;
|
||||
}
|
||||
@@ -123,25 +125,25 @@ export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({
|
||||
|
||||
await upsertInstance(nextInstance);
|
||||
await connect(nextInstance.id);
|
||||
toast.success('Retried with a random local port');
|
||||
toast.success(t('settings.remoteInstances.sidebar.toast.retriedWithRandomPort'));
|
||||
}
|
||||
}, [connect, upsertInstance]);
|
||||
}, [connect, t, upsertInstance]);
|
||||
|
||||
return (
|
||||
<SettingsSidebarLayout
|
||||
variant="background"
|
||||
header={
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">Remote Instances</h2>
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.remoteInstances.sidebar.title')}</h2>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">Total {instances.length}</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.remoteInstances.sidebar.total', { count: instances.length })}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 -my-1 text-muted-foreground"
|
||||
onClick={() => void handleAdd()}
|
||||
aria-label="Add SSH instance"
|
||||
aria-label={t('settings.remoteInstances.sidebar.actions.addSshInstance')}
|
||||
>
|
||||
<RiAddLine className="size-4" />
|
||||
</Button>
|
||||
@@ -153,7 +155,7 @@ export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({
|
||||
const status = statusesById[instance.id];
|
||||
const selected = instance.id === selectedId;
|
||||
const title = instance.nickname?.trim() || instance.sshParsed?.destination || instance.id;
|
||||
const metadata = `${phaseLabel(status?.phase)}${status?.localUrl ? ` · ${status.localUrl}` : ''}`;
|
||||
const metadata = `${t(phaseLabelKey(status?.phase))}${status?.localUrl ? ` · ${status.localUrl}` : ''}`;
|
||||
const isReady = status?.phase === 'ready';
|
||||
const canRetry = status?.phase === 'error' || status?.phase === 'degraded';
|
||||
|
||||
@@ -169,31 +171,36 @@ export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({
|
||||
}}
|
||||
actions={[
|
||||
{
|
||||
label: isReady ? 'Disconnect' : 'Connect',
|
||||
label: isReady ? t('settings.remoteInstances.sidebar.actions.disconnect') : t('settings.remoteInstances.sidebar.actions.connect'),
|
||||
icon: isReady ? RiStopLine : RiPlug2Line,
|
||||
onClick: () => {
|
||||
const op = isReady ? disconnect(instance.id) : connectWithPortRecovery(instance);
|
||||
void op.catch((error) => {
|
||||
toast.error(`Failed to ${isReady ? 'disconnect' : 'connect'} instance`, {
|
||||
toast.error(
|
||||
isReady
|
||||
? t('settings.remoteInstances.sidebar.toast.disconnectFailed')
|
||||
: t('settings.remoteInstances.sidebar.toast.connectFailed'),
|
||||
{
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Retry',
|
||||
label: t('settings.remoteInstances.sidebar.actions.retry'),
|
||||
icon: RiRefreshLine,
|
||||
onClick: () => {
|
||||
if (!canRetry) return;
|
||||
void retry(instance.id).catch((error) => {
|
||||
toast.error('Failed to retry connection', {
|
||||
toast.error(t('settings.remoteInstances.sidebar.toast.retryFailed'), {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Remove',
|
||||
label: t('settings.remoteInstances.sidebar.actions.remove'),
|
||||
icon: RiDeleteBinLine,
|
||||
destructive: true,
|
||||
onClick: () => {
|
||||
@@ -203,7 +210,7 @@ export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({
|
||||
setSelectedId(next?.id || null);
|
||||
}
|
||||
}).catch((error) => {
|
||||
toast.error('Failed to remove instance', {
|
||||
toast.error(t('settings.remoteInstances.sidebar.toast.removeFailed'), {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,12 +10,14 @@ import { RiArrowDownSLine, RiFolderLine } from '@remixicon/react';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
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());
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -39,7 +41,7 @@ export const SettingsProjectSelector: React.FC<{ className?: string }> = ({ clas
|
||||
|
||||
const rawLabel = activeProject?.label && activeProject.label.trim().length > 0
|
||||
? activeProject.label
|
||||
: (activeProject?.path.split('/').filter(Boolean).pop() || activeProject?.path || 'Project');
|
||||
: (activeProject?.path.split('/').filter(Boolean).pop() || activeProject?.path || t('settings.shared.projectSelector.fallbackProject'));
|
||||
const label = formatProjectLabel(rawLabel);
|
||||
|
||||
return (
|
||||
@@ -48,8 +50,8 @@ export const SettingsProjectSelector: React.FC<{ className?: string }> = ({ clas
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Switch project"
|
||||
title="Switch project"
|
||||
aria-label={t('settings.shared.projectSelector.switchProjectAria')}
|
||||
title={t('settings.shared.projectSelector.switchProjectTitle')}
|
||||
className={cn(
|
||||
// Mirror Input sizing so headers align visually.
|
||||
'text-foreground border border-border/80 appearance-none flex h-8 w-full min-w-0 rounded-lg bg-transparent px-3 py-1 outline-none',
|
||||
|
||||
@@ -23,11 +23,11 @@ import {
|
||||
import { SkillsCatalogPage } from './catalog/SkillsCatalogPage';
|
||||
import {
|
||||
SKILL_LOCATION_OPTIONS,
|
||||
locationLabel,
|
||||
locationPartsFrom,
|
||||
locationValueFrom,
|
||||
type SkillLocationValue,
|
||||
} from './skillLocations';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export interface SkillsPageProps {
|
||||
view?: 'installed' | 'catalog';
|
||||
@@ -38,6 +38,7 @@ const SkillsCatalogStandalone: React.FC = () => (
|
||||
);
|
||||
|
||||
const SkillsInstalledPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
selectedSkillName,
|
||||
getSkillByName,
|
||||
@@ -92,6 +93,32 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
? newFileContent !== originalFileContent
|
||||
: newFileName.trim() !== '';
|
||||
|
||||
const locationLabelText = React.useCallback((value: SkillLocationValue) => {
|
||||
switch (value) {
|
||||
case 'project-opencode':
|
||||
return t('settings.skills.location.option.projectOpencode.label');
|
||||
case 'user-agents':
|
||||
return t('settings.skills.location.option.userAgents.label');
|
||||
case 'project-agents':
|
||||
return t('settings.skills.location.option.projectAgents.label');
|
||||
default:
|
||||
return t('settings.skills.location.option.userOpencode.label');
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const locationDescriptionText = React.useCallback((value: SkillLocationValue) => {
|
||||
switch (value) {
|
||||
case 'project-opencode':
|
||||
return t('settings.skills.location.option.projectOpencode.description');
|
||||
case 'user-agents':
|
||||
return t('settings.skills.location.option.userAgents.description');
|
||||
case 'project-agents':
|
||||
return t('settings.skills.location.option.projectAgents.description');
|
||||
default:
|
||||
return t('settings.skills.location.option.userOpencode.description');
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const loadSkillDetails = async () => {
|
||||
if (isNewSkill && skillDraft) {
|
||||
@@ -131,22 +158,22 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
const skillName = isNewSkill ? draftName.trim().replace(/\s+/g, '-').toLowerCase() : selectedSkillName?.trim();
|
||||
|
||||
if (!skillName) {
|
||||
toast.error('Skill name is required');
|
||||
toast.error(t('settings.skills.page.toast.skillNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(skillName) || skillName.length > 64) {
|
||||
toast.error('Skill name must be 1-64 lowercase alphanumeric characters with hyphens, cannot start or end with hyphen');
|
||||
toast.error(t('settings.skills.page.toast.invalidSkillName'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!description.trim()) {
|
||||
toast.error('Description is required');
|
||||
toast.error(t('settings.skills.page.toast.descriptionRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNewSkill && skills.some((s) => s.name === skillName)) {
|
||||
toast.error('A skill with this name already exists');
|
||||
toast.error(t('settings.skills.page.toast.skillExists'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -179,13 +206,13 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
}
|
||||
|
||||
if (success) {
|
||||
toast.success(isNewSkill ? 'Skill created successfully' : 'Skill updated successfully');
|
||||
toast.success(isNewSkill ? t('settings.skills.page.toast.skillCreated') : t('settings.skills.page.toast.skillUpdated'));
|
||||
} else {
|
||||
toast.error(isNewSkill ? 'Failed to create skill' : 'Failed to update skill');
|
||||
toast.error(isNewSkill ? t('settings.skills.page.toast.createSkillFailed') : t('settings.skills.page.toast.updateSkillFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving skill:', error);
|
||||
toast.error('An error occurred while saving');
|
||||
toast.error(t('settings.skills.page.toast.saveUnexpectedError'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -223,7 +250,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
setNewFileContent(content || '');
|
||||
setOriginalFileContent(content || '');
|
||||
} catch {
|
||||
toast.error('Failed to load file content');
|
||||
toast.error(t('settings.skills.page.toast.loadFileContentFailed'));
|
||||
setNewFileContent('');
|
||||
setOriginalFileContent('');
|
||||
} finally {
|
||||
@@ -233,7 +260,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
|
||||
const handleSaveFile = async () => {
|
||||
if (!newFileName.trim()) {
|
||||
toast.error('File name is required');
|
||||
toast.error(t('settings.skills.page.toast.fileNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -245,14 +272,14 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
setPendingFiles(prev => prev.map(f =>
|
||||
f.path === editingFilePath ? { path: filePath, content: newFileContent } : f
|
||||
));
|
||||
toast.success(`File "${filePath}" updated`);
|
||||
toast.success(t('settings.skills.page.toast.fileUpdated', { path: filePath }));
|
||||
} else {
|
||||
if (pendingFiles.some(f => f.path === filePath)) {
|
||||
toast.error('A file with this name already exists');
|
||||
toast.error(t('settings.skills.page.toast.fileExists'));
|
||||
return;
|
||||
}
|
||||
setPendingFiles(prev => [...prev, { path: filePath, content: newFileContent }]);
|
||||
toast.success(`File "${filePath}" added`);
|
||||
toast.success(t('settings.skills.page.toast.fileAdded', { path: filePath }));
|
||||
}
|
||||
setIsFileDialogOpen(false);
|
||||
setEditingFilePath(null);
|
||||
@@ -260,7 +287,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
}
|
||||
|
||||
if (!selectedSkillName) {
|
||||
toast.error('No skill selected');
|
||||
toast.error(t('settings.skills.page.toast.noSkillSelected'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -268,7 +295,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
const success = await writeSupportingFile(selectedSkillName, filePath, newFileContent);
|
||||
|
||||
if (success) {
|
||||
toast.success(isEditing ? `File "${filePath}" updated` : `File "${filePath}" created`);
|
||||
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);
|
||||
@@ -276,14 +303,14 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
setSupportingFiles(detail.sources.md.supportingFiles || []);
|
||||
}
|
||||
} else {
|
||||
toast.error(isEditing ? 'Failed to update file' : 'Failed to create file');
|
||||
toast.error(isEditing ? t('settings.skills.page.toast.updateFileFailed') : t('settings.skills.page.toast.createFileFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFile = (filePath: string) => {
|
||||
if (isNewSkill) {
|
||||
setPendingFiles(prev => prev.filter(f => f.path !== filePath));
|
||||
toast.success(`File "${filePath}" removed`);
|
||||
toast.success(t('settings.skills.page.toast.fileRemoved', { path: filePath }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -304,14 +331,14 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
const success = await deleteSupportingFile(selectedSkillName, deleteFilePath);
|
||||
|
||||
if (success) {
|
||||
toast.success(`File "${deleteFilePath}" deleted`);
|
||||
toast.success(t('settings.skills.page.toast.fileDeleted', { path: deleteFilePath }));
|
||||
const detail = await getSkillDetail(selectedSkillName);
|
||||
if (detail) {
|
||||
setSupportingFiles(detail.sources.md.supportingFiles || []);
|
||||
}
|
||||
setDeleteFilePath(null);
|
||||
} else {
|
||||
toast.error('Failed to delete file');
|
||||
toast.error(t('settings.skills.page.toast.deleteFileFailed'));
|
||||
}
|
||||
|
||||
setIsDeletingFile(false);
|
||||
@@ -322,8 +349,8 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<RiBookOpenLine className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
||||
<p className="typography-body">Select a skill from the sidebar</p>
|
||||
<p className="typography-meta mt-1 opacity-75">or create a new one</p>
|
||||
<p className="typography-body">{t('settings.skills.page.empty.title')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.skills.page.empty.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -333,7 +360,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p className="typography-body">Loading skill details...</p>
|
||||
<p className="typography-body">{t('settings.skills.page.loading.details')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -347,15 +374,19 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<div className="mb-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground truncate flex items-center gap-2">
|
||||
{isNewSkill ? 'New Skill' : selectedSkillName}
|
||||
{isNewSkill ? t('settings.skills.page.title.newSkill') : selectedSkillName}
|
||||
{selectedSkill?.source === 'claude' && (
|
||||
<span className="typography-micro font-normal bg-[var(--surface-muted)] text-muted-foreground px-1.5 py-0.5 rounded">
|
||||
Claude-compatible
|
||||
{t('settings.skills.page.badge.claudeCompatible')}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<p className="typography-meta text-muted-foreground truncate">
|
||||
{selectedSkill ? `${locationLabel(selectedSkill.scope, selectedSkill.source)} skill` : 'Configure a new skill'}
|
||||
{selectedSkill
|
||||
? t('settings.skills.page.subtitle.skillLocation', {
|
||||
location: locationLabelText(locationValueFrom(selectedSkill.scope, selectedSkill.source)),
|
||||
})
|
||||
: t('settings.skills.page.subtitle.newSkill')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -364,7 +395,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Basic Information
|
||||
{t('settings.skills.page.section.basicInformation')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -372,13 +403,13 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
|
||||
{isNewSkill && (
|
||||
<div className="py-1.5">
|
||||
<span className="typography-ui-label text-foreground">Skill Name & Location</span>
|
||||
<span className="typography-meta text-muted-foreground ml-2">Lowercase, numbers, hyphens</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.skills.page.field.skillNameLocation')}</span>
|
||||
<span className="typography-meta text-muted-foreground ml-2">{t('settings.skills.page.field.skillNameHint')}</span>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<Input
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/\s+/g, '-'))}
|
||||
placeholder="skill-name"
|
||||
placeholder={t('settings.skills.page.field.skillNamePlaceholder')}
|
||||
className="h-7 w-40 px-2"
|
||||
/>
|
||||
<Select
|
||||
@@ -396,7 +427,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<RiFolderLine className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{draftSource === 'agents' ? <RiRobot2Line className="h-3.5 w-3.5" /> : null}
|
||||
<span>{locationLabel(draftScope, draftSource)}</span>
|
||||
<span>{locationLabelText(locationValueFrom(draftScope, draftSource))}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{SKILL_LOCATION_OPTIONS.map((option) => (
|
||||
@@ -405,9 +436,9 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<div className="flex items-center gap-2">
|
||||
{option.scope === 'user' ? <RiUser3Line className="h-3.5 w-3.5" /> : <RiFolderLine className="h-3.5 w-3.5" />}
|
||||
{option.source === 'agents' ? <RiRobot2Line className="h-3.5 w-3.5" /> : null}
|
||||
<span>{option.label}</span>
|
||||
<span>{locationLabelText(option.value)}</span>
|
||||
</div>
|
||||
<span className="typography-micro text-muted-foreground ml-6">{option.description}</span>
|
||||
<span className="typography-micro text-muted-foreground ml-6">{locationDescriptionText(option.value)}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -418,13 +449,13 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
<div className="py-1.5">
|
||||
<span className="typography-ui-label text-foreground">Description <span className="text-[var(--status-error)]">*</span></span>
|
||||
<span className="typography-meta text-muted-foreground ml-2">The agent uses this to decide when to load the skill</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.common.field.description')} <span className="text-[var(--status-error)]">*</span></span>
|
||||
<span className="typography-meta text-muted-foreground ml-2">{t('settings.skills.page.field.descriptionHint')}</span>
|
||||
<div className="mt-1.5">
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of what this skill does..."
|
||||
placeholder={t('settings.skills.page.field.descriptionPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full resize-none min-h-[60px] max-h-32 bg-transparent"
|
||||
/>
|
||||
@@ -438,7 +469,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Instructions
|
||||
{t('settings.skills.page.section.instructions')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -446,7 +477,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<Textarea
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
placeholder="Step-by-step instructions, guidelines, or reference content..."
|
||||
placeholder={t('settings.skills.page.field.instructionsPlaceholder')}
|
||||
className="min-h-[220px] max-h-[60vh] font-mono typography-meta"
|
||||
/>
|
||||
</section>
|
||||
@@ -456,10 +487,10 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<div className="mb-2">
|
||||
<div className="mb-1 px-1 flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Supporting Files
|
||||
{t('settings.skills.page.section.supportingFiles')}
|
||||
</h3>
|
||||
<Button variant="outline" size="xs" className="!font-normal gap-1" onClick={handleAddFile}>
|
||||
<RiAddLine className="h-3.5 w-3.5" /> Add File
|
||||
<RiAddLine className="h-3.5 w-3.5" /> {t('settings.skills.page.actions.addFile')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -470,7 +501,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
if (filesToShow.length === 0) {
|
||||
return (
|
||||
<p className="typography-meta text-muted-foreground py-1.5">
|
||||
No supporting files. Use "Add File" to include reference materials.
|
||||
{t('settings.skills.page.supportingFiles.empty')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -487,7 +518,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<span className="typography-ui-label text-foreground truncate">{file.path}</span>
|
||||
{isNewSkill && (
|
||||
<span className="typography-micro text-[var(--status-warning)] bg-[var(--status-warning)]/10 px-1.5 py-0.5 rounded flex-shrink-0">
|
||||
pending
|
||||
{t('settings.skills.page.badge.pending')}
|
||||
</span>
|
||||
)}
|
||||
<Button size="sm"
|
||||
@@ -516,7 +547,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
>
|
||||
{isSaving ? 'Saving...' : isNewSkill ? 'Create Skill' : 'Save Changes'}
|
||||
{isSaving ? t('settings.common.actions.saving') : isNewSkill ? t('settings.skills.page.actions.createSkill') : t('settings.common.actions.saveChanges')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -533,9 +564,9 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Supporting File</DialogTitle>
|
||||
<DialogTitle>{t('settings.skills.page.deleteFileDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{deleteFilePath}"?
|
||||
{t('settings.skills.page.deleteFileDialog.description', { path: deleteFilePath ?? '' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -545,10 +576,10 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
onClick={() => setDeleteFilePath(null)}
|
||||
disabled={isDeletingFile}
|
||||
>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={handleConfirmDeleteFile} disabled={isDeletingFile}>
|
||||
Delete
|
||||
{t('settings.common.actions.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -560,42 +591,42 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
}}>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>{editingFilePath ? 'Edit Supporting File' : 'Add Supporting File'}</DialogTitle>
|
||||
<DialogTitle>{editingFilePath ? t('settings.skills.page.fileDialog.titleEdit') : t('settings.skills.page.fileDialog.titleAdd')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingFilePath ? 'Modify the file content' : 'Create a new file in the skill directory'}
|
||||
{editingFilePath ? t('settings.skills.page.fileDialog.descriptionEdit') : t('settings.skills.page.fileDialog.descriptionAdd')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isLoadingFile ? (
|
||||
<div className="flex-1 flex items-center justify-center py-8">
|
||||
<span className="typography-meta text-muted-foreground">Loading file content...</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.skills.page.loading.fileContent')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 flex-1 min-h-0 flex flex-col pt-2">
|
||||
<div className="space-y-2 flex-shrink-0">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
File Path
|
||||
{t('settings.skills.page.fileDialog.field.filePath')}
|
||||
</label>
|
||||
<Input
|
||||
value={newFileName}
|
||||
onChange={(e) => setNewFileName(e.target.value)}
|
||||
placeholder="example.md or docs/reference.txt"
|
||||
placeholder={t('settings.skills.page.fileDialog.field.filePathPlaceholder')}
|
||||
className="text-foreground placeholder:text-muted-foreground focus-visible:ring-[var(--primary-base)]"
|
||||
disabled={editingFilePath !== null}
|
||||
/>
|
||||
{!editingFilePath && (
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Relative path within the skill directory. Subdirectories will be created automatically.
|
||||
{t('settings.skills.page.fileDialog.field.filePathHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2 flex-1 min-h-0 flex flex-col">
|
||||
<label className="typography-ui-label font-medium text-foreground flex-shrink-0">
|
||||
Content
|
||||
{t('settings.skills.page.fileDialog.field.content')}
|
||||
</label>
|
||||
<Textarea
|
||||
value={newFileContent}
|
||||
onChange={(e) => setNewFileContent(e.target.value)}
|
||||
placeholder="File content..."
|
||||
placeholder={t('settings.skills.page.fileDialog.field.contentPlaceholder')}
|
||||
outerClassName="h-[45vh] min-h-[250px] max-h-[55vh]"
|
||||
className="h-full min-h-0 font-mono typography-meta"
|
||||
/>
|
||||
@@ -611,10 +642,10 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
setEditingFilePath(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSaveFile} disabled={isLoadingFile || !hasFileChanges}>
|
||||
{editingFilePath ? 'Save Changes' : 'Create File'}
|
||||
{editingFilePath ? t('settings.common.actions.saveChanges') : t('settings.skills.page.actions.createFile')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -23,12 +23,14 @@ import { cn } from '@/lib/utils';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
|
||||
import { SidebarGroup } from '@/components/sections/shared/SidebarGroup';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface SkillsSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
}
|
||||
|
||||
export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const [renameDialogSkill, setRenameDialogSkill] = React.useState<DiscoveredSkill | null>(null);
|
||||
const [renameNewName, setRenameNewName] = React.useState('');
|
||||
const [deleteDialogSkill, setDeleteDialogSkill] = React.useState<DiscoveredSkill | null>(null);
|
||||
@@ -79,10 +81,10 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
setIsDeletePending(true);
|
||||
const success = await deleteSkill(deleteDialogSkill.name);
|
||||
if (success) {
|
||||
toast.success(`Skill "${deleteDialogSkill.name}" deleted successfully`);
|
||||
toast.success(t('settings.skills.sidebar.toast.skillDeleted', { name: deleteDialogSkill.name }));
|
||||
setDeleteDialogSkill(null);
|
||||
} else {
|
||||
toast.error('Failed to delete skill');
|
||||
toast.error(t('settings.skills.sidebar.toast.deleteSkillFailed'));
|
||||
}
|
||||
setIsDeletePending(false);
|
||||
};
|
||||
@@ -100,7 +102,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
// Get full skill detail to copy
|
||||
const detail = await getSkillDetail(skill.name);
|
||||
if (!detail) {
|
||||
toast.error('Failed to load skill details for duplication');
|
||||
toast.error(t('settings.skills.sidebar.toast.duplicateLoadFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -128,7 +130,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
const sanitizedName = renameNewName.trim().replace(/\s+/g, '-').toLowerCase();
|
||||
|
||||
if (!sanitizedName) {
|
||||
toast.error('Skill name is required');
|
||||
toast.error(t('settings.skills.page.toast.skillNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -138,14 +140,14 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
}
|
||||
|
||||
if (skills.some((s) => s.name === sanitizedName)) {
|
||||
toast.error('A skill with this name already exists');
|
||||
toast.error(t('settings.skills.page.toast.skillExists'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get full detail to copy
|
||||
const detail = await getSkillDetail(renameDialogSkill.name);
|
||||
if (!detail) {
|
||||
toast.error('Failed to load skill details');
|
||||
toast.error(t('settings.skills.sidebar.toast.renameLoadFailed'));
|
||||
setRenameDialogSkill(null);
|
||||
return;
|
||||
}
|
||||
@@ -165,10 +167,10 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
toast.success(`Skill renamed to "${sanitizedName}"`);
|
||||
setSelectedSkill(sanitizedName);
|
||||
} else {
|
||||
toast.error('Failed to remove old skill after rename');
|
||||
toast.error(t('settings.skills.sidebar.toast.removeOldAfterRenameFailed'));
|
||||
}
|
||||
} else {
|
||||
toast.error('Failed to rename skill');
|
||||
toast.error(t('settings.skills.sidebar.toast.renameFailed'));
|
||||
}
|
||||
|
||||
setRenameDialogSkill(null);
|
||||
@@ -206,10 +208,10 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">Skills</h2>
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.skills.sidebar.title')}</h2>
|
||||
<SettingsProjectSelector className="mb-3" />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">Total {skills.length}</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.skills.sidebar.total', { count: skills.length })}</span>
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
|
||||
@@ -224,15 +226,15 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
{skills.length === 0 ? (
|
||||
<div className="py-12 px-4 text-center text-muted-foreground">
|
||||
<RiBookOpenLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
||||
<p className="typography-ui-label font-medium">No skills configured</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Use the + button above to create one</p>
|
||||
<p className="typography-ui-label font-medium">{t('settings.skills.sidebar.empty.title')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.skills.sidebar.empty.description')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{projectSkills.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Project Skills
|
||||
{t('settings.skills.sidebar.section.project')}
|
||||
</div>
|
||||
{groupedProjectSkills.sortedGroups.map(({ name: groupName, skills: groupSkills }) => (
|
||||
<SidebarGroup
|
||||
@@ -283,7 +285,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
{userSkills.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pb-1.5 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
User Skills
|
||||
{t('settings.skills.sidebar.section.user')}
|
||||
</div>
|
||||
{groupedUserSkills.sortedGroups.map(({ name: groupName, skills: groupSkills }) => (
|
||||
<SidebarGroup
|
||||
@@ -344,9 +346,9 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Skill</DialogTitle>
|
||||
<DialogTitle>{t('settings.skills.sidebar.deleteDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete skill "{deleteDialogSkill?.name}"?
|
||||
{t('settings.skills.sidebar.deleteDialog.description', { name: deleteDialogSkill?.name ?? '' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -357,10 +359,10 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
onClick={() => setDeleteDialogSkill(null)}
|
||||
disabled={isDeletePending}
|
||||
>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleConfirmDeleteSkill} disabled={isDeletePending}>
|
||||
Delete
|
||||
{t('settings.common.actions.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -370,15 +372,15 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
<Dialog open={renameDialogSkill !== null} onOpenChange={(open) => !open && setRenameDialogSkill(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename Skill</DialogTitle>
|
||||
<DialogTitle>{t('settings.skills.sidebar.renameDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter a new name for the skill "{renameDialogSkill?.name}"
|
||||
{t('settings.skills.sidebar.renameDialog.description', { name: renameDialogSkill?.name ?? '' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={renameNewName}
|
||||
onChange={(e) => setRenameNewName(e.target.value)}
|
||||
placeholder="New skill name..."
|
||||
placeholder={t('settings.skills.sidebar.renameDialog.placeholder')}
|
||||
className="text-foreground placeholder:text-muted-foreground"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
@@ -393,10 +395,10 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
variant="ghost"
|
||||
onClick={() => setRenameDialogSkill(null)}
|
||||
>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleRenameSkill}>
|
||||
Rename
|
||||
{t('settings.common.actions.rename')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -426,6 +428,7 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
|
||||
isMenuOpen,
|
||||
onMenuOpenChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isMobile = isMobileDeviceViaCSS();
|
||||
return (
|
||||
<div
|
||||
@@ -453,12 +456,12 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
|
||||
</span>
|
||||
{skill.source === 'claude' && (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
claude
|
||||
{t('settings.skills.sidebar.badge.claude')}
|
||||
</span>
|
||||
)}
|
||||
{skill.source === 'agents' && (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
agents
|
||||
{t('settings.skills.sidebar.badge.agents')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -481,7 +484,7 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
|
||||
}}
|
||||
>
|
||||
<RiEditLine className="h-4 w-4 mr-px" />
|
||||
Rename
|
||||
{t('settings.common.actions.rename')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
@@ -491,7 +494,7 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
|
||||
}}
|
||||
>
|
||||
<RiFileCopyLine className="h-4 w-4 mr-px" />
|
||||
Duplicate
|
||||
{t('settings.common.actions.duplicate')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
@@ -502,7 +505,7 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-px" />
|
||||
Delete
|
||||
{t('settings.common.actions.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
|
||||
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const generateCatalogId = () => `custom:${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
@@ -77,6 +78,7 @@ interface AddCatalogDialogProps {
|
||||
}
|
||||
|
||||
export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpenChange }) => {
|
||||
const { t } = useI18n();
|
||||
const { scanRepo, loadCatalog, isScanning } = useSkillsCatalogStore();
|
||||
const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId);
|
||||
const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId);
|
||||
@@ -126,7 +128,7 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
const handleScan = async () => {
|
||||
const trimmedSource = source.trim();
|
||||
if (!trimmedSource) {
|
||||
toast.error('Repository source is required');
|
||||
toast.error(t('settings.skills.catalog.add.toast.repositoryRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -146,7 +148,7 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
if (!result.ok) {
|
||||
if (result.error?.kind === 'authRequired') {
|
||||
if (isVSCodeRuntime()) {
|
||||
toast.error('Private repositories are not supported in VS Code yet');
|
||||
toast.error(t('settings.skills.catalog.shared.toast.privateRepoNotSupportedVsCode'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -161,25 +163,25 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
: ids[0].id;
|
||||
setGitIdentityId(preferred);
|
||||
}
|
||||
toast.error('Authentication required. Select a Git identity and scan again.');
|
||||
toast.error(t('settings.skills.catalog.add.toast.authenticationRequiredScan'));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error?.message || 'Failed to scan repository');
|
||||
toast.error(result.error?.message || t('settings.skills.catalog.add.toast.scanFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
const count = result.items?.length || 0;
|
||||
setScanCount(count);
|
||||
if (count === 0) {
|
||||
toast.error('No skills found in this repository');
|
||||
toast.error(t('settings.skills.catalog.add.toast.noSkillsFound'));
|
||||
setScanOk(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIdentityOptions([]);
|
||||
setScanOk(true);
|
||||
toast.success(`Found ${count} skill(s)`);
|
||||
toast.success(t('settings.skills.catalog.shared.toast.foundSkills', { count }));
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
@@ -188,22 +190,22 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
const trimmedSubpath = subpath.trim();
|
||||
|
||||
if (!trimmedLabel) {
|
||||
toast.error('Catalog name is required');
|
||||
toast.error(t('settings.skills.catalog.add.toast.catalogNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trimmedSource) {
|
||||
toast.error('Repository source is required');
|
||||
toast.error(t('settings.skills.catalog.add.toast.repositoryRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scanOk) {
|
||||
toast.error('Scan the repository before adding this catalog');
|
||||
toast.error(t('settings.skills.catalog.add.toast.scanBeforeAdd'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDuplicate) {
|
||||
toast.error('This catalog already exists');
|
||||
toast.error(t('settings.skills.catalog.add.toast.catalogAlreadyExists'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -220,11 +222,11 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
try {
|
||||
await updateDesktopSettings({ skillCatalogs: updated });
|
||||
setExistingCatalogs(updated);
|
||||
toast.success('Catalog added');
|
||||
toast.success(t('settings.skills.catalog.add.toast.catalogAdded'));
|
||||
await loadCatalog({ refresh: true });
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to save catalog');
|
||||
toast.error(error instanceof Error ? error.message : t('settings.skills.catalog.add.toast.saveFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -232,20 +234,23 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add skills catalog</DialogTitle>
|
||||
<DialogTitle>{t('settings.skills.catalog.add.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a Git repository as a new catalog source. OpenChamber will scan it for folders containing <code className="font-mono">SKILL.md</code>.
|
||||
{t('settings.skills.catalog.add.descriptionPrefix')}
|
||||
{' '}
|
||||
<code className="font-mono">SKILL.md</code>
|
||||
{t('settings.skills.catalog.add.descriptionSuffix')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label text-foreground">Catalog name</label>
|
||||
<Input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="e.g. Team Skills" />
|
||||
<label className="typography-ui-label text-foreground">{t('settings.skills.catalog.add.field.catalogName')}</label>
|
||||
<Input value={label} onChange={(e) => setLabel(e.target.value)} placeholder={t('settings.skills.catalog.add.field.catalogNamePlaceholder')} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label text-foreground">Repository</label>
|
||||
<label className="typography-ui-label text-foreground">{t('settings.skills.catalog.add.field.repository')}</label>
|
||||
<Input
|
||||
value={source}
|
||||
onChange={(e) => {
|
||||
@@ -253,15 +258,15 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
setScanOk(false);
|
||||
setScanCount(null);
|
||||
}}
|
||||
placeholder="owner/repo or git@github.com:owner/repo.git"
|
||||
placeholder={t('settings.skills.catalog.shared.field.repositoryPlaceholder')}
|
||||
/>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Public repos work everywhere. Private repos require SSH identity (Desktop/Web only).
|
||||
{t('settings.skills.catalog.add.field.repositoryHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label text-foreground">Optional subpath</label>
|
||||
<label className="typography-ui-label text-foreground">{t('settings.skills.catalog.add.field.optionalSubpath')}</label>
|
||||
<Input
|
||||
value={subpath}
|
||||
onChange={(e) => {
|
||||
@@ -269,19 +274,19 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
setScanOk(false);
|
||||
setScanCount(null);
|
||||
}}
|
||||
placeholder="e.g. skills"
|
||||
placeholder={t('settings.skills.catalog.shared.field.subpathPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{identityOptions.length > 0 && !isVSCodeRuntime() ? (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<span className="typography-ui-label text-[var(--status-warning)]">Authentication required</span>
|
||||
<span className="typography-meta text-muted-foreground ml-2">Select a Git identity (SSH key)</span>
|
||||
<span className="typography-ui-label text-[var(--status-warning)]">{t('settings.skills.catalog.shared.auth.title')}</span>
|
||||
<span className="typography-meta text-muted-foreground ml-2">{t('settings.skills.catalog.shared.auth.description')}</span>
|
||||
</div>
|
||||
<Select value={gitIdentityId || ''} onValueChange={(v) => setGitIdentityId(v)}>
|
||||
<SelectTrigger className="w-fit">
|
||||
<span>{identityOptions.find((i) => i.id === gitIdentityId)?.name || 'Choose identity'}</span>
|
||||
<span>{identityOptions.find((i) => i.id === gitIdentityId)?.name || t('settings.skills.catalog.shared.auth.chooseIdentity')}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{identityOptions.map((id) => (
|
||||
@@ -292,27 +297,27 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Configure identities in Settings - Git Identities.
|
||||
{t('settings.skills.catalog.shared.auth.footerHint')}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{scanCount !== null ? (
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
Scan result: {scanCount} skill(s) found
|
||||
{t('settings.skills.catalog.add.scanResult', { count: scanCount })}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isDuplicate ? (
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
This catalog is already added.
|
||||
{t('settings.skills.catalog.add.duplicateMessage')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button size="sm" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -322,14 +327,14 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
disabled={isScanning || !source.trim()}
|
||||
>
|
||||
<RiGitRepositoryLine className="h-4 w-4" />
|
||||
{isScanning ? 'Scanning...' : 'Scan'}
|
||||
{isScanning ? t('settings.skills.catalog.shared.actions.scanning') : t('settings.skills.catalog.shared.actions.scan')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleAdd()}
|
||||
disabled={!scanOk || isDuplicate || !label.trim() || !source.trim()}
|
||||
>
|
||||
Add catalog
|
||||
{t('settings.skills.catalog.add.actions.addCatalog')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@/components/ui/select';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export type SkillConflict = {
|
||||
skillName: string;
|
||||
@@ -37,6 +38,7 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
|
||||
conflicts,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [decisions, setDecisions] = React.useState<Record<string, ConflictDecision>>({});
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -62,18 +64,18 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Skills already exist</DialogTitle>
|
||||
<DialogTitle>{t('settings.skills.catalog.conflicts.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Some selected skills are already installed in this scope. Choose whether to skip or overwrite them.
|
||||
{t('settings.skills.catalog.conflicts.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">{conflicts.length} conflict(s)</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.skills.catalog.conflicts.count', { count: conflicts.length })}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => setAll('skip')}>Skip all</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => setAll('overwrite')}>Overwrite all</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => setAll('skip')}>{t('settings.skills.catalog.conflicts.actions.skipAll')}</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => setAll('overwrite')}>{t('settings.skills.catalog.conflicts.actions.overwriteAll')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -86,7 +88,14 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
|
||||
<div className="min-w-0">
|
||||
<div className="typography-ui-label truncate">{conflict.skillName}</div>
|
||||
<div className="typography-micro text-muted-foreground">
|
||||
Installed in {conflict.scope} / {conflict.source || 'opencode'}
|
||||
{t('settings.skills.catalog.conflicts.installedIn', {
|
||||
scope: conflict.scope === 'project'
|
||||
? t('settings.skills.catalog.conflicts.scope.project')
|
||||
: t('settings.skills.catalog.conflicts.scope.user'),
|
||||
source: conflict.source === 'agents'
|
||||
? t('settings.skills.catalog.conflicts.source.agents')
|
||||
: t('settings.skills.catalog.conflicts.source.opencode'),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,14 +104,18 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
|
||||
onValueChange={(v) => setDecisions((prev) => ({ ...prev, [conflict.skillName]: v as ConflictDecision }))}
|
||||
>
|
||||
<SelectTrigger className="w-fit">
|
||||
<span className="capitalize">{decisions[conflict.skillName] || 'skip'}</span>
|
||||
<span className="capitalize">
|
||||
{(decisions[conflict.skillName] || 'skip') === 'overwrite'
|
||||
? t('settings.skills.catalog.conflicts.decision.overwrite')
|
||||
: t('settings.skills.catalog.conflicts.decision.skip')}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="skip" className="pr-2 [&>span:first-child]:hidden">
|
||||
Skip
|
||||
{t('settings.skills.catalog.conflicts.decision.skip')}
|
||||
</SelectItem>
|
||||
<SelectItem value="overwrite" className="pr-2 [&>span:first-child]:hidden">
|
||||
Overwrite
|
||||
{t('settings.skills.catalog.conflicts.decision.overwrite')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -113,14 +126,14 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
|
||||
|
||||
<DialogFooter>
|
||||
<Button size="sm" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onConfirm(decisions)}
|
||||
disabled={!canConfirm}
|
||||
>
|
||||
Continue
|
||||
{t('settings.skills.catalog.conflicts.actions.continue')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -29,9 +29,9 @@ import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
SKILL_LOCATION_OPTIONS,
|
||||
locationLabel,
|
||||
locationPartsFrom,
|
||||
locationValueFrom,
|
||||
type SkillLocationValue,
|
||||
@@ -45,6 +45,7 @@ interface InstallFromRepoDialogProps {
|
||||
type IdentityOption = { id: string; name: string };
|
||||
|
||||
export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ open, onOpenChange }) => {
|
||||
const { t } = useI18n();
|
||||
const { scanRepo, installSkills, isScanning, isInstalling } = useSkillsCatalogStore();
|
||||
const installedSkills = useSkillsStore((s) => s.skills);
|
||||
const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId);
|
||||
@@ -153,10 +154,36 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
setSelected(next);
|
||||
};
|
||||
|
||||
const locationLabelText = React.useCallback((value: SkillLocationValue) => {
|
||||
switch (value) {
|
||||
case 'project-opencode':
|
||||
return t('settings.skills.location.option.projectOpencode.label');
|
||||
case 'user-agents':
|
||||
return t('settings.skills.location.option.userAgents.label');
|
||||
case 'project-agents':
|
||||
return t('settings.skills.location.option.projectAgents.label');
|
||||
default:
|
||||
return t('settings.skills.location.option.userOpencode.label');
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const locationDescriptionText = React.useCallback((value: SkillLocationValue) => {
|
||||
switch (value) {
|
||||
case 'project-opencode':
|
||||
return t('settings.skills.location.option.projectOpencode.description');
|
||||
case 'user-agents':
|
||||
return t('settings.skills.location.option.userAgents.description');
|
||||
case 'project-agents':
|
||||
return t('settings.skills.location.option.projectAgents.description');
|
||||
default:
|
||||
return t('settings.skills.location.option.userOpencode.description');
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleScan = async () => {
|
||||
const trimmed = source.trim();
|
||||
if (!trimmed) {
|
||||
toast.error('Repository source is required');
|
||||
toast.error(t('settings.skills.catalog.shared.toast.repositoryRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -169,7 +196,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
if (!result.ok) {
|
||||
if (result.error?.kind === 'authRequired') {
|
||||
if (isVSCodeRuntime()) {
|
||||
toast.error('Private repositories are not supported in VS Code yet');
|
||||
toast.error(t('settings.skills.catalog.shared.toast.privateRepoNotSupportedVsCode'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -184,11 +211,11 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
: ids[0].id;
|
||||
setGitIdentityId(preferred);
|
||||
}
|
||||
toast.error('Authentication required. Select a Git identity and try scanning again.');
|
||||
toast.error(t('settings.skills.catalog.installFromRepo.toast.authenticationRequiredScan'));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error?.message || 'Failed to scan repository');
|
||||
toast.error(result.error?.message || t('settings.skills.catalog.installFromRepo.toast.scanFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -205,12 +232,12 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
setSelected(nextSelected);
|
||||
|
||||
setIdentities([]);
|
||||
toast.success(`Found ${nextItems.length} skill(s)`);
|
||||
toast.success(t('settings.skills.catalog.shared.toast.foundSkills', { count: nextItems.length }));
|
||||
};
|
||||
|
||||
const doInstall = async (opts: { conflictDecisions?: Record<string, ConflictDecision> }) => {
|
||||
if (selectedDirs.length === 0) {
|
||||
toast.error('Select at least one skill to install');
|
||||
toast.error(t('settings.skills.catalog.installFromRepo.toast.selectAtLeastOne'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -240,7 +267,11 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
|
||||
if (result.ok) {
|
||||
const installedCount = result.installed?.length || 0;
|
||||
toast.success(installedCount > 0 ? `Installed ${installedCount} skill(s)` : 'Installation completed');
|
||||
toast.success(
|
||||
installedCount > 0
|
||||
? t('settings.skills.catalog.installFromRepo.toast.installedCount', { count: installedCount })
|
||||
: t('settings.skills.catalog.installFromRepo.toast.installCompleted')
|
||||
);
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
@@ -254,7 +285,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
|
||||
if (result.error?.kind === 'authRequired') {
|
||||
if (isVSCodeRuntime()) {
|
||||
toast.error('Private repositories are not supported in VS Code yet');
|
||||
toast.error(t('settings.skills.catalog.shared.toast.privateRepoNotSupportedVsCode'));
|
||||
return;
|
||||
}
|
||||
const ids = (result.error.identities || []) as IdentityOption[];
|
||||
@@ -268,11 +299,11 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
: ids[0].id;
|
||||
setGitIdentityId(preferred);
|
||||
}
|
||||
toast.error('Authentication required. Select a Git identity and try installing again.');
|
||||
toast.error(t('settings.skills.catalog.installFromRepo.toast.authenticationRequiredInstall'));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error?.message || 'Failed to install skills');
|
||||
toast.error(result.error?.message || t('settings.skills.catalog.installFromRepo.toast.installFailed'));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -280,20 +311,23 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>Install from Git repository</DialogTitle>
|
||||
<DialogTitle>{t('settings.skills.catalog.installFromRepo.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Scan a repository for folders containing <code className="font-mono">SKILL.md</code>, then install selected skills.
|
||||
{t('settings.skills.catalog.installFromRepo.descriptionPrefix')}
|
||||
{' '}
|
||||
<code className="font-mono">SKILL.md</code>
|
||||
{t('settings.skills.catalog.installFromRepo.descriptionSuffix')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 flex-shrink-0">
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">Repository</label>
|
||||
<label className="typography-ui-label font-medium text-foreground">{t('settings.skills.catalog.shared.field.repository')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
placeholder="owner/repo or git@github.com:owner/repo.git"
|
||||
placeholder={t('settings.skills.catalog.shared.field.repositoryPlaceholder')}
|
||||
className="text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
<Button
|
||||
@@ -304,27 +338,30 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
className="gap-2"
|
||||
>
|
||||
<RiGitRepositoryLine className="h-4 w-4" />
|
||||
{isScanning ? 'Scanning…' : 'Scan'}
|
||||
{isScanning ? t('settings.skills.catalog.shared.actions.scanning') : t('settings.skills.catalog.shared.actions.scan')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
For GitHub shorthand, you can add a subpath like <code className="font-mono">owner/repo/skills</code>.
|
||||
{t('settings.skills.catalog.installFromRepo.repositoryHintPrefix')}
|
||||
{' '}
|
||||
<code className="font-mono">owner/repo/skills</code>
|
||||
{'.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">Optional subpath</label>
|
||||
<label className="typography-ui-label font-medium text-foreground">{t('settings.skills.catalog.shared.field.optionalSubpath')}</label>
|
||||
<Input
|
||||
value={subpath}
|
||||
onChange={(e) => setSubpath(e.target.value)}
|
||||
placeholder="e.g. skills"
|
||||
placeholder={t('settings.skills.catalog.shared.field.subpathPlaceholder')}
|
||||
className="text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">Target location</label>
|
||||
<label className="typography-ui-label font-medium text-foreground">{t('settings.skills.catalog.shared.field.targetLocation')}</label>
|
||||
<Select
|
||||
value={locationValueFrom(scope, targetSource)}
|
||||
onValueChange={(v) => {
|
||||
@@ -336,7 +373,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
<SelectTrigger size="lg" className="w-full gap-1.5">
|
||||
{scope === 'user' ? <RiUser3Line className="h-4 w-4" /> : <RiFolderLine className="h-4 w-4" />}
|
||||
{targetSource === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null}
|
||||
<span>{locationLabel(scope, targetSource)}</span>
|
||||
<span>{locationLabelText(locationValueFrom(scope, targetSource))}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{SKILL_LOCATION_OPTIONS.map((option) => (
|
||||
@@ -345,9 +382,9 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
<div className="flex items-center gap-2">
|
||||
{option.scope === 'user' ? <RiUser3Line className="h-4 w-4" /> : <RiFolderLine className="h-4 w-4" />}
|
||||
{option.source === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null}
|
||||
<span>{option.label}</span>
|
||||
<span>{locationLabelText(option.value)}</span>
|
||||
</div>
|
||||
<span className="typography-micro text-muted-foreground ml-6">{option.description}</span>
|
||||
<span className="typography-micro text-muted-foreground ml-6">{locationDescriptionText(option.value)}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -358,9 +395,9 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
|
||||
{scope === 'project' && (
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">Project</label>
|
||||
<label className="typography-ui-label font-medium text-foreground">{t('settings.skills.catalog.shared.field.project')}</label>
|
||||
{projects.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">No projects available</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.skills.catalog.shared.field.noProjects')}</p>
|
||||
) : (
|
||||
<Select
|
||||
value={resolvedTargetProjectId ?? ''}
|
||||
@@ -368,7 +405,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
disabled={projects.length === 1}
|
||||
>
|
||||
<SelectTrigger size="lg" className="w-full justify-between">
|
||||
<SelectValue placeholder="Choose project" />
|
||||
<SelectValue placeholder={t('settings.skills.catalog.shared.field.chooseProjectPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{projects.map((p) => (
|
||||
@@ -384,14 +421,14 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
|
||||
{identities.length > 0 && !isVSCodeRuntime() ? (
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
||||
<div className="typography-ui-label font-medium text-foreground">Authentication required</div>
|
||||
<div className="typography-ui-label font-medium text-foreground">{t('settings.skills.catalog.shared.auth.title')}</div>
|
||||
<div className="typography-meta text-muted-foreground mt-1">
|
||||
Select a Git identity (SSH key) that can access this repository.
|
||||
{t('settings.skills.catalog.installFromRepo.authDescription')}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Select value={gitIdentityId || ''} onValueChange={(v) => setGitIdentityId(v)}>
|
||||
<SelectTrigger size="lg" className="w-full justify-between">
|
||||
<span>{identities.find((i) => i.id === gitIdentityId)?.name || 'Choose identity'}</span>
|
||||
<span>{identities.find((i) => i.id === gitIdentityId)?.name || t('settings.skills.catalog.shared.auth.chooseIdentity')}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{identities.map((id) => (
|
||||
@@ -403,7 +440,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
</Select>
|
||||
</div>
|
||||
<div className="typography-micro text-muted-foreground mt-2">
|
||||
Configure identities in Settings → Git Identities.
|
||||
{t('settings.skills.catalog.shared.auth.footerHintArrow')}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -413,8 +450,8 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
{items.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-center text-muted-foreground">
|
||||
<div>
|
||||
<p className="typography-body">No scan results yet</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Scan a repository to discover skills</p>
|
||||
<p className="typography-body">{t('settings.skills.catalog.installFromRepo.empty.noScanResultsTitle')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.skills.catalog.installFromRepo.empty.noScanResultsDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -423,12 +460,12 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search skills…"
|
||||
placeholder={t('settings.skills.catalog.shared.field.searchSkillsPlaceholder')}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => toggleAll(true)}>Select all</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => toggleAll(false)}>Select none</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => toggleAll(true)}>{t('settings.skills.catalog.installFromRepo.actions.selectAll')}</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => toggleAll(false)}>{t('settings.skills.catalog.installFromRepo.actions.selectNone')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -458,14 +495,17 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
<div className="typography-ui-label truncate">{item.skillName}</div>
|
||||
{installed ? (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
installed ({installed.scope}/{installed.source})
|
||||
{t('settings.skills.catalog.installFromRepo.badge.installed', {
|
||||
scope: installed.scope,
|
||||
source: installed.source,
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{item.description ? (
|
||||
<div className="typography-meta text-muted-foreground mt-0.5 line-clamp-2">{item.description}</div>
|
||||
) : (
|
||||
<div className="typography-micro text-muted-foreground mt-0.5">No description provided</div>
|
||||
<div className="typography-micro text-muted-foreground mt-0.5">{t('settings.skills.catalog.shared.noDescription')}</div>
|
||||
)}
|
||||
{item.warnings?.length ? (
|
||||
<div className="typography-micro text-muted-foreground mt-1">
|
||||
@@ -479,7 +519,10 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
</ScrollableOverlay>
|
||||
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
Selected: {selectedDirs.length} / {items.filter((i) => i.installable).length}
|
||||
{t('settings.skills.catalog.installFromRepo.selectedCount', {
|
||||
selected: selectedDirs.length,
|
||||
total: items.filter((i) => i.installable).length,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -487,14 +530,14 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
|
||||
<DialogFooter className="flex-shrink-0">
|
||||
<Button size="sm" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isInstalling || selectedDirs.length === 0 || !source.trim() || (scope === 'project' && !directoryOverride)}
|
||||
onClick={() => void doInstall({})}
|
||||
>
|
||||
{isInstalling ? 'Installing…' : 'Install selected'}
|
||||
{isInstalling ? t('settings.skills.catalog.shared.actions.installing') : t('settings.skills.catalog.installFromRepo.actions.installSelected')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { RiFolderLine, RiRobot2Line, RiUser3Line } from '@remixicon/react';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
import type { SkillsCatalogItem } from '@/lib/api/types';
|
||||
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||
@@ -25,7 +26,6 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog';
|
||||
import {
|
||||
SKILL_LOCATION_OPTIONS,
|
||||
locationLabel,
|
||||
locationPartsFrom,
|
||||
locationValueFrom,
|
||||
type SkillLocationValue,
|
||||
@@ -38,6 +38,7 @@ interface InstallSkillDialogProps {
|
||||
}
|
||||
|
||||
export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, onOpenChange, item }) => {
|
||||
const { t } = useI18n();
|
||||
const { installSkills, isInstalling } = useSkillsCatalogStore();
|
||||
const [scope, setScope] = React.useState<'user' | 'project'>('user');
|
||||
const [targetSource, setTargetSource] = React.useState<'opencode' | 'agents'>('opencode');
|
||||
@@ -65,6 +66,32 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
setBaseRequest(null);
|
||||
}, [open, activeProjectId]);
|
||||
|
||||
const locationLabelText = React.useCallback((value: SkillLocationValue) => {
|
||||
switch (value) {
|
||||
case 'project-opencode':
|
||||
return t('settings.skills.location.option.projectOpencode.label');
|
||||
case 'user-agents':
|
||||
return t('settings.skills.location.option.userAgents.label');
|
||||
case 'project-agents':
|
||||
return t('settings.skills.location.option.projectAgents.label');
|
||||
default:
|
||||
return t('settings.skills.location.option.userOpencode.label');
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const locationDescriptionText = React.useCallback((value: SkillLocationValue) => {
|
||||
switch (value) {
|
||||
case 'project-opencode':
|
||||
return t('settings.skills.location.option.projectOpencode.description');
|
||||
case 'user-agents':
|
||||
return t('settings.skills.location.option.userAgents.description');
|
||||
case 'project-agents':
|
||||
return t('settings.skills.location.option.projectAgents.description');
|
||||
default:
|
||||
return t('settings.skills.location.option.userOpencode.description');
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const resolvedTargetProjectId = React.useMemo(() => {
|
||||
if (projects.length === 0) {
|
||||
return null;
|
||||
@@ -122,7 +149,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
}, { directory: request.directoryOverride ?? null });
|
||||
|
||||
if (result.ok) {
|
||||
toast.success('Skill installed successfully');
|
||||
toast.success(t('settings.skills.catalog.installSkill.toast.installed'));
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
@@ -142,11 +169,11 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
}
|
||||
|
||||
if (result.error?.kind === 'authRequired') {
|
||||
toast.error(result.error.message || 'Authentication required');
|
||||
toast.error(result.error.message || t('settings.skills.catalog.installSkill.toast.authRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error?.message || 'Failed to install skill');
|
||||
toast.error(result.error?.message || t('settings.skills.catalog.installSkill.toast.installFailed'));
|
||||
};
|
||||
|
||||
if (!item) {
|
||||
@@ -158,15 +185,19 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Install skill</DialogTitle>
|
||||
<DialogTitle>{t('settings.skills.catalog.installSkill.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Install <span className="font-semibold text-foreground">{item.skillName}</span> into one of four target locations.
|
||||
{t('settings.skills.catalog.installSkill.descriptionPrefix')}
|
||||
{' '}
|
||||
<span className="font-semibold text-foreground">{item.skillName}</span>
|
||||
{' '}
|
||||
{t('settings.skills.catalog.installSkill.descriptionSuffix')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-2 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="typography-ui-label text-foreground">Destination</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.skills.catalog.installSkill.field.destination')}</span>
|
||||
<Select
|
||||
value={locationValueFrom(scope, targetSource)}
|
||||
onValueChange={(v) => {
|
||||
@@ -178,7 +209,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
<SelectTrigger className="w-fit gap-1.5">
|
||||
{scope === 'user' ? <RiUser3Line className="h-3.5 w-3.5" /> : <RiFolderLine className="h-3.5 w-3.5" />}
|
||||
{targetSource === 'agents' ? <RiRobot2Line className="h-3.5 w-3.5" /> : null}
|
||||
<span>{locationLabel(scope, targetSource)}</span>
|
||||
<span>{locationLabelText(locationValueFrom(scope, targetSource))}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{SKILL_LOCATION_OPTIONS.map((option) => (
|
||||
@@ -187,9 +218,9 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
<div className="flex items-center gap-2">
|
||||
{option.scope === 'user' ? <RiUser3Line className="h-3.5 w-3.5" /> : <RiFolderLine className="h-3.5 w-3.5" />}
|
||||
{option.source === 'agents' ? <RiRobot2Line className="h-3.5 w-3.5" /> : null}
|
||||
<span>{option.label}</span>
|
||||
<span>{locationLabelText(option.value)}</span>
|
||||
</div>
|
||||
<span className="typography-micro text-muted-foreground ml-5">{option.description}</span>
|
||||
<span className="typography-micro text-muted-foreground ml-5">{locationDescriptionText(option.value)}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -199,9 +230,9 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
|
||||
{scope === 'project' && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="typography-ui-label text-foreground">Project</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.skills.catalog.installSkill.field.project')}</span>
|
||||
{projects.length === 0 ? (
|
||||
<span className="typography-meta text-muted-foreground">No projects available</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.skills.catalog.installSkill.field.noProjects')}</span>
|
||||
) : (
|
||||
<Select
|
||||
value={resolvedTargetProjectId ?? ''}
|
||||
@@ -209,7 +240,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
disabled={projects.length === 1}
|
||||
>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectValue placeholder="Choose project" />
|
||||
<SelectValue placeholder={t('settings.skills.catalog.installSkill.field.chooseProjectPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{projects.map((p) => (
|
||||
@@ -236,7 +267,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -252,7 +283,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
})
|
||||
}
|
||||
>
|
||||
{isInstalling ? 'Installing...' : 'Install'}
|
||||
{isInstalling ? t('settings.skills.catalog.installSkill.actions.installing') : t('settings.skills.catalog.installSkill.actions.install')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { SkillsCatalogItem } 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 { AddCatalogDialog } from './AddCatalogDialog';
|
||||
import { InstallSkillDialog } from './InstallSkillDialog';
|
||||
@@ -65,6 +66,7 @@ const loadSettings = async (): Promise<DesktopSettings | null> => {
|
||||
};
|
||||
|
||||
export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onModeChange, showModeTabs = true }) => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
sources,
|
||||
itemsBySource,
|
||||
@@ -154,8 +156,8 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
<div className="h-10">
|
||||
<SortableTabsStrip
|
||||
items={[
|
||||
{ id: 'manual', label: 'Manual' },
|
||||
{ id: 'external', label: 'External' },
|
||||
{ id: 'manual', label: t('settings.skills.catalog.page.mode.manual') },
|
||||
{ id: 'external', label: t('settings.skills.catalog.page.mode.external') },
|
||||
]}
|
||||
activeId={mode}
|
||||
onSelect={(next) => onModeChange(next as 'manual' | 'external')}
|
||||
@@ -167,13 +169,13 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<h2 className="typography-ui-header font-semibold text-foreground px-1">Skills Catalog</h2>
|
||||
<h2 className="typography-ui-header font-semibold text-foreground px-1">{t('settings.skills.catalog.page.title')}</h2>
|
||||
</div>
|
||||
|
||||
{/* Source & Search */}
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Source Repository</h3>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.skills.catalog.page.section.sourceRepository')}</h3>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-0">
|
||||
@@ -183,7 +185,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
onValueChange={(v) => setSelectedSource(v)}
|
||||
>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectValue placeholder="Select source" />
|
||||
<SelectValue placeholder={t('settings.skills.catalog.page.field.selectSourcePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{sources.map((src) => (
|
||||
@@ -206,7 +208,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
}
|
||||
}}
|
||||
disabled={isLoadingCatalog || isLoadingSource}
|
||||
title="Refresh"
|
||||
title={t('settings.skills.catalog.page.actions.refreshTitle')}
|
||||
>
|
||||
<RiRefreshLine className={cn("h-3.5 w-3.5", (isLoadingCatalog || isLoadingSource) && "animate-spin")} />
|
||||
</Button>
|
||||
@@ -218,7 +220,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
className="!font-normal h-6 w-6 px-0 text-[var(--status-error)] hover:text-[var(--status-error)]"
|
||||
onClick={() => setIsRemoveCatalogDialogOpen(true)}
|
||||
disabled={isRemovingCatalog}
|
||||
title="Remove Catalog"
|
||||
title={t('settings.skills.catalog.page.actions.removeCatalogTitle')}
|
||||
>
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -229,7 +231,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
className="!font-normal gap-1"
|
||||
onClick={() => setAddCatalogOpen(true)}
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" /> Add Catalog
|
||||
<RiAddLine className="h-3.5 w-3.5" /> {t('settings.skills.catalog.page.actions.addCatalog')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -239,12 +241,14 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search skills..."
|
||||
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 ? 'Loading...' : `${filtered.length} skill(s) found`}
|
||||
{isLoadingCatalog
|
||||
? t('settings.skills.catalog.page.loading.catalog')
|
||||
: t('settings.skills.catalog.page.foundCount', { count: filtered.length })}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -253,7 +257,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
{/* Error State */}
|
||||
{lastCatalogError && (
|
||||
<div className="mb-8 rounded-lg border border-[var(--status-error-border)] bg-[var(--status-error-background)] px-4 py-3">
|
||||
<div className="typography-ui-label font-medium text-[var(--status-error)]">Catalog error</div>
|
||||
<div className="typography-ui-label font-medium text-[var(--status-error)]">{t('settings.skills.catalog.page.error.catalogTitle')}</div>
|
||||
<div className="typography-meta text-[var(--status-error)]/80 mt-1">{lastCatalogError.message}</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -263,13 +267,13 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
{filtered.length === 0 && !isLoadingSource ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<p className="typography-body">No skills found</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Try a different search or refresh the catalog</p>
|
||||
<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="py-8 text-center text-muted-foreground">
|
||||
<RiRefreshLine className="mx-auto mb-3 h-5 w-5 animate-spin opacity-50" />
|
||||
<p className="typography-meta">Loading skills...</p>
|
||||
<p className="typography-meta">{t('settings.skills.catalog.page.loading.skills')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--surface-subtle)]">
|
||||
@@ -285,12 +289,12 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
<span className="typography-ui-label font-medium text-foreground truncate">{item.skillName}</span>
|
||||
{installed && (
|
||||
<span className="typography-micro text-[var(--status-success)] bg-[var(--status-success)]/10 px-1.5 py-0.5 rounded flex-shrink-0">
|
||||
installed ({installedScope || 'unknown'})
|
||||
{t('settings.skills.catalog.page.badge.installed', { scope: installedScope || t('settings.skills.catalog.page.badge.unknown') })}
|
||||
</span>
|
||||
)}
|
||||
{!item.installable && (
|
||||
<span className="typography-micro text-[var(--status-warning)] bg-[var(--status-warning)]/10 px-1.5 py-0.5 rounded flex-shrink-0">
|
||||
not installable
|
||||
{t('settings.skills.catalog.page.badge.notInstallable')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -298,13 +302,13 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
{item.description ? (
|
||||
<div className="typography-meta text-muted-foreground mt-0.5 line-clamp-2">{item.description}</div>
|
||||
) : (
|
||||
<div className="typography-meta text-muted-foreground/50 mt-0.5 italic">No description provided</div>
|
||||
<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>by <span className="font-medium text-foreground/80">{item.clawdhub.owner}</span></span>
|
||||
<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">
|
||||
<RiDownloadLine className="h-3 w-3" />
|
||||
@@ -337,7 +341,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
setInstallDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Install
|
||||
{t('settings.skills.catalog.shared.actions.install')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -356,7 +360,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
onClick={() => void loadMoreClawdHub()}
|
||||
disabled={isLoadingMore}
|
||||
>
|
||||
{isLoadingMore ? 'Loading...' : 'Load More Skills'}
|
||||
{isLoadingMore ? t('settings.skills.catalog.page.loading.more') : t('settings.skills.catalog.page.actions.loadMoreSkills')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -376,8 +380,8 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove Catalog</DialogTitle>
|
||||
<DialogDescription>Are you sure you want to remove this catalog?</DialogDescription>
|
||||
<DialogTitle>{t('settings.skills.catalog.page.removeDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('settings.skills.catalog.page.removeDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -386,10 +390,10 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
onClick={() => setIsRemoveCatalogDialogOpen(false)}
|
||||
disabled={isRemovingCatalog}
|
||||
>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => void removeSelectedCatalog()} disabled={isRemovingCatalog}>
|
||||
Remove Catalog
|
||||
{t('settings.skills.catalog.page.actions.removeCatalog')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,6 +2,7 @@ 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;
|
||||
@@ -19,22 +20,23 @@ export const PaceIndicator: React.FC<PaceIndicatorProps> = ({
|
||||
className,
|
||||
compact = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const statusColor = getPaceStatusColor(paceInfo.status);
|
||||
|
||||
const statusLabel = React.useMemo(() => {
|
||||
switch (paceInfo.status) {
|
||||
switch (paceInfo.status) {
|
||||
case 'on-track':
|
||||
return 'On track';
|
||||
return t('settings.usage.pace.status.onTrack');
|
||||
case 'slightly-fast':
|
||||
return 'Slightly fast';
|
||||
return t('settings.usage.pace.status.slightlyFast');
|
||||
case 'too-fast':
|
||||
return 'Too fast';
|
||||
return t('settings.usage.pace.status.tooFast');
|
||||
case 'exhausted':
|
||||
return 'Used up';
|
||||
}
|
||||
}, [paceInfo.status]);
|
||||
return t('settings.usage.pace.status.usedUp');
|
||||
}
|
||||
}, [paceInfo.status, t]);
|
||||
|
||||
const predictionTooltip = `Predicted usage at window end based on current pace: ${paceInfo.predictText}`;
|
||||
const predictionTooltip = t('settings.usage.pace.predictionTooltip', { prediction: paceInfo.predictText });
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
@@ -50,9 +52,9 @@ export const PaceIndicator: React.FC<PaceIndicatorProps> = ({
|
||||
title={paceInfo.isExhausted ? undefined : predictionTooltip}
|
||||
>
|
||||
{paceInfo.isExhausted ? (
|
||||
<>Wait {formatRemainingTime(paceInfo.remainingSeconds)}</>
|
||||
<>{t('settings.usage.pace.wait', { duration: formatRemainingTime(paceInfo.remainingSeconds) })}</>
|
||||
) : (
|
||||
<>Pred: {paceInfo.predictText}</>
|
||||
<>{t('settings.usage.pace.prediction', { prediction: paceInfo.predictText })}</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -64,7 +66,7 @@ export const PaceIndicator: React.FC<PaceIndicatorProps> = ({
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!paceInfo.isExhausted && (
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
Pace: {paceInfo.paceRateText}
|
||||
{t('settings.usage.pace.rate', { rate: paceInfo.paceRateText })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -76,12 +78,12 @@ export const PaceIndicator: React.FC<PaceIndicatorProps> = ({
|
||||
{paceInfo.isExhausted ? (
|
||||
<>
|
||||
<span className="font-medium">{statusLabel}</span>
|
||||
<span className="text-muted-foreground"> · Wait </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">Pred: </span>
|
||||
<span className="text-muted-foreground">{t('settings.usage.pace.predictionLabel')}</span>
|
||||
<span className="font-medium">{paceInfo.predictText}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { RiArrowDownSLine, RiArrowRightSLine, RiInformationLine } from '@remixic
|
||||
import type { UsageWindows, QuotaProviderId } from '@/types';
|
||||
import { getAllModelFamilies, getDisplayModelName, sortModelFamilies, groupModelsByFamilyWithGetter } from '@/lib/quota/model-families';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const formatTime = (timestamp: number | null) => {
|
||||
if (!timestamp) return '-';
|
||||
@@ -34,6 +35,7 @@ interface ModelInfo {
|
||||
}
|
||||
|
||||
export const UsagePage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const results = useQuotaStore((state) => state.results);
|
||||
const selectedProviderId = useQuotaStore((state) => state.selectedProviderId);
|
||||
const setSelectedProvider = useQuotaStore((state) => state.setSelectedProvider);
|
||||
@@ -70,7 +72,7 @@ export const UsagePage: React.FC = () => {
|
||||
const selectedResult = results.find((entry) => entry.providerId === selectedProviderId) ?? null;
|
||||
|
||||
const providerMeta = QUOTA_PROVIDERS.find((provider) => provider.id === selectedProviderId);
|
||||
const providerName = providerMeta?.name ?? selectedProviderId ?? 'Usage';
|
||||
const providerName = providerMeta?.name ?? selectedProviderId ?? t('settings.usage.sidebar.title');
|
||||
const usage = selectedResult?.usage;
|
||||
const showInDropdown = selectedProviderId ? dropdownProviderIds.includes(selectedProviderId) : false;
|
||||
const handleDropdownToggle = React.useCallback((enabled: boolean) => {
|
||||
@@ -141,8 +143,8 @@ export const UsagePage: React.FC = () => {
|
||||
|
||||
if (!selectedProviderId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p className="typography-body">Select a provider to view usage details.</p>
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p className="typography-body">{t('settings.usage.page.empty.selectProvider')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -156,13 +158,13 @@ export const UsagePage: React.FC = () => {
|
||||
<ProviderLogo providerId={selectedProviderId} className="h-5 w-5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground truncate">
|
||||
{providerName} Usage
|
||||
{t('settings.usage.page.header.providerUsage', { provider: providerName })}
|
||||
</h2>
|
||||
<p className="typography-meta text-muted-foreground truncate">
|
||||
{isLoading ? (
|
||||
<span className="animate-pulse">Refreshing usage...</span>
|
||||
<span className="animate-pulse">{t('settings.usage.page.header.refreshing')}</span>
|
||||
) : (
|
||||
`Last updated: ${formatTime(lastUpdated)}`
|
||||
t('settings.usage.page.header.lastUpdated', { time: formatTime(lastUpdated) })
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -183,19 +185,19 @@ export const UsagePage: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={showInDropdown}
|
||||
onChange={handleDropdownToggle}
|
||||
ariaLabel="Show in header menu"
|
||||
/>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="typography-ui-label text-foreground">Show in Header Menu</span>
|
||||
<Checkbox
|
||||
checked={showInDropdown}
|
||||
onChange={handleDropdownToggle}
|
||||
ariaLabel={t('settings.usage.page.options.showInHeaderAria')}
|
||||
/>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.usage.page.options.showInHeader')}</span>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
When enabled, this provider's usage will be visible in the quick access dropdown menu in the app header.
|
||||
{t('settings.usage.page.options.showInHeaderTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -205,22 +207,22 @@ export const UsagePage: React.FC = () => {
|
||||
{/* State Messages */}
|
||||
{!selectedResult && (
|
||||
<div className="mb-8 px-2">
|
||||
<p className="typography-ui-label text-foreground">No usage data available yet.</p>
|
||||
<p className="typography-ui-label text-foreground">{t('settings.usage.page.state.noData')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<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)]">Failed to refresh usage data</p>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedResult && !selectedResult.configured && (
|
||||
<div className="mb-8 rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] px-4 py-3">
|
||||
<p className="typography-ui-label font-medium text-[var(--status-warning)]">Provider not configured</p>
|
||||
<p className="typography-ui-label font-medium text-[var(--status-warning)]">{t('settings.usage.page.state.providerNotConfiguredTitle')}</p>
|
||||
<p className="typography-meta text-[var(--status-warning)]/80 mt-1">
|
||||
Add credentials in the Providers tab to enable usage tracking.
|
||||
{t('settings.usage.page.state.providerNotConfiguredDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -242,7 +244,7 @@ export const UsagePage: React.FC = () => {
|
||||
{providerModels.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Model Quotas</h3>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.usage.page.section.modelQuotas')}</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
@@ -314,7 +316,7 @@ export const UsagePage: React.FC = () => {
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between py-0.5 group">
|
||||
<div className="flex items-center gap-1.5 text-left">
|
||||
<span className="typography-ui-label font-normal text-foreground">Other Models</span>
|
||||
<span className="typography-ui-label font-normal text-foreground">{t('settings.usage.page.section.otherModels')}</span>
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
({otherModels.length})
|
||||
</span>
|
||||
@@ -358,8 +360,8 @@ export const UsagePage: React.FC = () => {
|
||||
{selectedResult?.configured && usage && Object.keys(usage.windows ?? {}).length === 0 &&
|
||||
providerModels.length === 0 && (
|
||||
<div className="mb-8 px-2">
|
||||
<p className="typography-ui-label text-foreground">No quota windows reported</p>
|
||||
<p className="typography-meta text-muted-foreground mt-1">This provider does not currently report any rate limits or usage quotas.</p>
|
||||
<p className="typography-ui-label text-foreground">{t('settings.usage.page.state.noQuotaWindowsTitle')}</p>
|
||||
<p className="typography-meta text-muted-foreground mt-1">{t('settings.usage.page.state.noQuotaWindowsDescription')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota';
|
||||
import { useQuotaStore } from '@/stores/useQuotaStore';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { RiRefreshLine } from '@remixicon/react';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface UsageSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
@@ -27,6 +28,7 @@ const getUsagePercent = (usage: { windows?: Record<string, { usedPercent: number
|
||||
};
|
||||
|
||||
export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const results = useQuotaStore((state) => state.results);
|
||||
const selectedProviderId = useQuotaStore((state) => state.selectedProviderId);
|
||||
const setSelectedProvider = useQuotaStore((state) => state.setSelectedProvider);
|
||||
@@ -79,9 +81,9 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">Usage</h2>
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.usage.sidebar.title')}</h2>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">Total {QUOTA_PROVIDERS.length}</span>
|
||||
<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 delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -89,12 +91,12 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
|
||||
<Checkbox
|
||||
checked={usageAutoRefresh}
|
||||
onChange={handleUsageAutoRefreshChange}
|
||||
ariaLabel="Toggle auto refresh"
|
||||
ariaLabel={t('settings.usage.sidebar.actions.toggleAutoRefreshAria')}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Auto-refresh usage data at set interval
|
||||
{t('settings.usage.sidebar.tooltip.autoRefresh')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Select
|
||||
@@ -103,7 +105,7 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
|
||||
disabled={!usageAutoRefresh}
|
||||
>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectValue placeholder="Interval" />
|
||||
<SelectValue placeholder={t('settings.usage.sidebar.field.intervalPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="30000">30s</SelectItem>
|
||||
@@ -115,8 +117,8 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
|
||||
variant="ghost"
|
||||
className="h-7 w-7 px-0 text-muted-foreground"
|
||||
onClick={() => fetchAllQuotas()}
|
||||
aria-label="Refresh usage"
|
||||
title="Refresh usage"
|
||||
aria-label={t('settings.usage.sidebar.actions.refreshAria')}
|
||||
title={t('settings.usage.sidebar.actions.refreshTitle')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RiRefreshLine className={cn('h-3.5 w-3.5', isLoading && 'animate-spin')} />
|
||||
@@ -124,14 +126,14 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<span className="typography-micro text-muted-foreground">Display</span>
|
||||
<span className="typography-micro text-muted-foreground">{t('settings.usage.sidebar.field.display')}</span>
|
||||
<Select value={usageDisplayMode} onValueChange={handleUsageDisplayModeChange}>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectValue placeholder="Display mode" />
|
||||
<SelectValue placeholder={t('settings.usage.sidebar.field.displayModePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="usage">Usage</SelectItem>
|
||||
<SelectItem value="remaining">Quota remaining</SelectItem>
|
||||
<SelectItem value="usage">{t('settings.usage.sidebar.field.displayModeUsage')}</SelectItem>
|
||||
<SelectItem value="remaining">{t('settings.usage.sidebar.field.displayModeRemaining')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -175,7 +177,7 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
|
||||
{provider.name}
|
||||
</span>
|
||||
{!configured && (
|
||||
<span className="typography-micro text-muted-foreground/60 flex-shrink-0">Not set</span>
|
||||
<span className="typography-micro text-muted-foreground/60 flex-shrink-0">{t('settings.usage.sidebar.status.notSet')}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user