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:
Bohdan Triapitsyn
2026-04-26 14:03:39 +03:00
committed by GitHub
parent 87db2ea210
commit 7d7285655d
198 changed files with 24173 additions and 4365 deletions
@@ -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 &middot;{' '}
<code className="text-foreground">!`cmd`</code> shell output &middot;{' '}
<code className="text-foreground">@file</code> file contents
<code className="text-foreground">$ARGUMENTS</code> {t('settings.commands.page.templateHint.userInput')} &middot;{' '}
<code className="text-foreground">!`cmd`</code> {t('settings.commands.page.templateHint.shellOutput')} &middot;{' '}
<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>