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
@@ -10,6 +10,7 @@ import { syncDesktopSettings, initializeAppearancePreferences } from '@/lib/pers
import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence';
import { DesktopHostSwitcherInline } from '@/components/desktop/DesktopHostSwitcher';
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import { useI18n } from '@/lib/i18n';
import {
authenticateWithPasskey,
cancelPasskeyCeremony,
@@ -89,6 +90,7 @@ const LoadingScreen: React.FC = () => (
);
const ErrorScreen: React.FC<ErrorScreenProps> = ({ onRetry, errorType = 'network', retryAfter }) => {
const { t } = useI18n();
const isRateLimit = errorType === 'rate-limit';
const minutes = retryAfter ? Math.ceil(retryAfter / 60) : 1;
@@ -97,16 +99,18 @@ const ErrorScreen: React.FC<ErrorScreenProps> = ({ onRetry, errorType = 'network
<div className="flex flex-col items-center gap-6 text-center">
<div className="space-y-2">
<h1 className="typography-ui-header font-semibold text-destructive">
{isRateLimit ? 'Too many attempts' : 'Unable to reach server'}
{isRateLimit ? t('sessionAuth.error.rateLimitTitle') : t('sessionAuth.error.networkTitle')}
</h1>
<p className="typography-meta text-muted-foreground max-w-xs">
{isRateLimit
? `Please wait ${minutes} minute${minutes > 1 ? 's' : ''} before trying again.`
: "We couldn't verify the UI session. Check that the service is running and try again."}
? (minutes > 1
? t('sessionAuth.error.rateLimitDescriptionPlural', { minutes })
: t('sessionAuth.error.rateLimitDescriptionSingle', { minutes }))
: t('sessionAuth.error.networkDescription')}
</p>
</div>
<Button type="button" onClick={onRetry} className="w-full max-w-xs">
Retry
{t('sessionAuth.error.retry')}
</Button>
</div>
</AuthShell>
@@ -126,6 +130,7 @@ interface ErrorScreenProps {
}
export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) => {
const { t } = useI18n();
const vscodeRuntime = React.useMemo(() => isVSCodeRuntime(), []);
const skipAuth = vscodeRuntime;
const showHostSwitcher = React.useMemo(() => isDesktopShell() && !vscodeRuntime, [vscodeRuntime]);
@@ -339,14 +344,14 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
if (enrollPasskey && supportsPasskeys) {
try {
await registerPasskeyForCurrentSession();
toast.success('Passkey added');
toast.success(t('sessionAuth.toast.passkeyAdded'));
setState('authenticated');
return;
} catch (error) {
if (isPasskeyCeremonyAbort(error)) {
toast.message('Passkey setup canceled');
toast.message(t('sessionAuth.toast.passkeySetupCanceled'));
} else {
const message = error instanceof Error ? error.message : 'Passkey setup failed.';
const message = error instanceof Error ? error.message : t('sessionAuth.error.passkeySetupFailed');
toast.error(message);
}
setState('authenticated');
@@ -359,7 +364,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
if (response.status === 401) {
console.warn('[Frontend Auth] Login failed: Invalid password');
setErrorMessage('Incorrect password. Try again.');
setErrorMessage(t('sessionAuth.error.incorrectPassword'));
setIsTunnelLocked(false);
setState('locked');
return;
@@ -375,18 +380,18 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
}
console.error('[Frontend Auth] Login failed: Unexpected response', response.status);
setErrorMessage('Unexpected response from server.');
setErrorMessage(t('sessionAuth.error.unexpectedResponse'));
setIsTunnelLocked(false);
setState('error');
} catch (error) {
console.warn('Failed to submit UI password:', error);
setErrorMessage('Network error. Check connection and retry.');
setErrorMessage(t('sessionAuth.error.networkRetry'));
setIsTunnelLocked(false);
setState('error');
} finally {
setIsSubmitting(false);
}
}, [cancelActivePasskey, isPasskeyBusy, isSubmitting, isTunnelLocked, password, registerPasskeyForCurrentSession, supportsPasskeys, trustDevice]);
}, [cancelActivePasskey, isPasskeyBusy, isSubmitting, isTunnelLocked, password, registerPasskeyForCurrentSession, supportsPasskeys, t, trustDevice]);
const handlePasskeyUnlock = React.useCallback(async () => {
if (isSubmitting || !supportsPasskeys) {
@@ -411,14 +416,14 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
if (isPasskeyCeremonyAbort(error)) {
setErrorMessage('');
} else {
const message = error instanceof Error ? error.message : 'Passkey sign-in was cancelled.';
const message = error instanceof Error ? error.message : t('sessionAuth.error.passkeySignInCanceled');
setErrorMessage(message);
}
} finally {
setActivePasskeyAction(null);
setIsPasskeyBusy(false);
}
}, [cancelActivePasskey, isPasskeyBusy, isSubmitting, supportsPasskeys, trustDevice]);
}, [cancelActivePasskey, isPasskeyBusy, isSubmitting, supportsPasskeys, t, trustDevice]);
const handlePasskeySetupOnly = React.useCallback(async () => {
if (isSubmitting || isTunnelLocked || !supportsPasskeys) {
@@ -432,7 +437,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
if (state !== 'authenticated') {
if (!password) {
setErrorMessage('Enter your password to add a passkey.');
setErrorMessage(t('sessionAuth.error.enterPasswordForPasskey'));
return;
}
await handlePasswordUnlock(true);
@@ -442,16 +447,16 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
setErrorMessage('');
try {
await registerPasskeyForCurrentSession();
toast.success('Passkey added');
toast.success(t('sessionAuth.toast.passkeyAdded'));
} catch (error) {
if (isPasskeyCeremonyAbort(error)) {
toast.message('Passkey setup canceled');
toast.message(t('sessionAuth.toast.passkeySetupCanceled'));
return;
}
const message = error instanceof Error ? error.message : 'Passkey setup failed.';
const message = error instanceof Error ? error.message : t('sessionAuth.error.passkeySetupFailed');
toast.error(message);
}
}, [cancelActivePasskey, handlePasswordUnlock, isPasskeyBusy, isSubmitting, isTunnelLocked, password, registerPasskeyForCurrentSession, state, supportsPasskeys]);
}, [cancelActivePasskey, handlePasswordUnlock, isPasskeyBusy, isSubmitting, isTunnelLocked, password, registerPasskeyForCurrentSession, state, supportsPasskeys, t]);
const canOfferPasskeySetup = supportsPasskeys && passkeyStatus.enabled;
const canUsePasskey = canOfferPasskeySetup && passkeyStatus.hasPasskeys;
@@ -474,12 +479,12 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
<div className="flex flex-col items-center gap-6 w-full max-w-xs">
<div className="flex flex-col items-center gap-1 text-center">
<h1 className="text-xl font-semibold text-foreground">
{isTunnelLocked ? 'Tunnel access required' : 'Unlock OpenChamber'}
{isTunnelLocked ? t('sessionAuth.locked.tunnelTitle') : t('sessionAuth.locked.unlockTitle')}
</h1>
<p className="typography-meta text-muted-foreground">
{isTunnelLocked
? 'Open this tunnel using the one-time connect link from the desktop app.'
: 'This session is password-protected.'}
? t('sessionAuth.locked.tunnelDescription')
: t('sessionAuth.locked.passwordDescription')}
</p>
</div>
@@ -498,7 +503,9 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
) : (
<RiLockUnlockLine className="h-4 w-4" />
)}
<span>{isPasskeyBusy && activePasskeyAction === 'auth' ? 'Cancel passkey' : 'Use passkey'}</span>
<span>{isPasskeyBusy && activePasskeyAction === 'auth'
? t('sessionAuth.actions.cancelPasskey')
: t('sessionAuth.actions.usePasskey')}</span>
</Button>
)}
<div className="flex items-center gap-2">
@@ -509,7 +516,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
ref={passwordInputRef}
type="password"
autoComplete="current-password"
placeholder="Enter password"
placeholder={t('sessionAuth.password.placeholder')}
value={password}
onChange={(event) => {
setPassword(event.target.value);
@@ -527,7 +534,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
type="submit"
size="icon"
disabled={!password || isSubmitting}
aria-label={isSubmitting ? 'Unlocking' : 'Unlock'}
aria-label={isSubmitting ? t('sessionAuth.actions.unlockingAria') : t('sessionAuth.actions.unlockAria')}
>
{isSubmitting ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
@@ -543,11 +550,11 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
checked={trustDevice}
onChange={setTrustDevice}
disabled={isSubmitting}
ariaLabel="Trust this device"
ariaLabel={t('sessionAuth.actions.trustDeviceAria')}
className="size-4"
iconClassName="size-4"
/>
<span>Trust this device</span>
<span>{t('sessionAuth.actions.trustDevice')}</span>
</label>
<Button
type="button"
@@ -557,7 +564,9 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
onClick={() => void handlePasskeySetupOnly()}
disabled={isSubmitting}
>
{isPasskeyBusy && activePasskeyAction === 'register' ? 'Cancel passkey setup' : 'Add passkey'}
{isPasskeyBusy && activePasskeyAction === 'register'
? t('sessionAuth.actions.cancelPasskeySetup')
: t('sessionAuth.actions.addPasskey')}
</Button>
</div>
) : (
@@ -566,11 +575,11 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
checked={trustDevice}
onChange={setTrustDevice}
disabled={isSubmitting}
ariaLabel="Trust this device"
ariaLabel={t('sessionAuth.actions.trustDeviceAria')}
className="size-4"
iconClassName="size-4"
/>
<span>Trust this device</span>
<span>{t('sessionAuth.actions.trustDevice')}</span>
</label>
)}
{errorMessage && (
@@ -585,7 +594,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
<div className="w-full">
<DesktopHostSwitcherInline />
<p className="mt-1 text-center typography-micro text-muted-foreground">
Use Local if remote is unreachable.
{t('sessionAuth.locked.hostSwitcherHint')}
</p>
</div>
)}
@@ -3,6 +3,7 @@ import { cn, fuzzyMatch } from '@/lib/utils';
import { useConfigStore } from '@/stores/useConfigStore';
import { useAgentsStore, isAgentBuiltIn, type AgentWithExtras } from '@/stores/useAgentsStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useI18n } from '@/lib/i18n';
interface AgentInfo {
name: string;
@@ -40,6 +41,7 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
activeTab = 'agents',
onTabSelect,
}, ref) => {
const { t } = useI18n();
const containerRef = React.useRef<HTMLDivElement | null>(null);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const [agents, setAgents] = React.useState<AgentInfo[]>([]);
@@ -157,7 +159,7 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
<span className="font-semibold">#{agent.name}</span>
{isSystem ? (
<span className="text-[10px] leading-none uppercase font-bold tracking-tight bg-[var(--status-warning-background)] text-[var(--status-warning)] border-[var(--status-warning-border)] px-1.5 py-1 rounded border flex-shrink-0">
system
{t('chat.agentMentionAutocomplete.badge.system')}
</span>
) : agent.scope ? (
<span className={cn(
@@ -180,6 +182,12 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
);
};
const tabs = React.useMemo(() => ([
{ id: 'commands' as const, label: t('chat.autocomplete.tabs.commands') },
{ id: 'agents' as const, label: t('chat.autocomplete.tabs.agents') },
{ id: 'files' as const, label: t('chat.autocomplete.tabs.files') },
]), [t]);
return (
<div
ref={containerRef}
@@ -188,11 +196,7 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
{showTabs ? (
<div className="px-2 pt-2 pb-1 border-b border-border/60">
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-elevated)] p-1">
{([
{ id: 'commands' as const, label: 'Commands' },
{ id: 'agents' as const, label: 'Agents' },
{ id: 'files' as const, label: 'Files' },
]).map((tab) => (
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
@@ -232,12 +236,12 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
</div>
) : (
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
No agents found
{t('chat.agentMentionAutocomplete.empty')}
</div>
)}
</ScrollableOverlay>
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
navigate Enter select Esc close
{t('chat.autocomplete.keyboardHint')}
</div>
</div>
);
@@ -1,6 +1,7 @@
import React from 'react';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { type ChangedFileEntry, getDisplayPath, getFileStats } from './changedFiles';
import { useI18n } from '@/lib/i18n';
interface ChangedFilesListProps {
files: ChangedFileEntry[];
@@ -9,10 +10,11 @@ interface ChangedFilesListProps {
}
export const ChangedFilesList: React.FC<ChangedFilesListProps> = ({ files, currentDirectory, onOpenFile }) => {
const { t } = useI18n();
return (
<>
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
<span>Changed files</span>
<span>{t('chat.changedFiles.title')}</span>
<span className="typography-meta tabular-nums">{files.length}</span>
</div>
@@ -26,7 +28,7 @@ export const ChangedFilesList: React.FC<ChangedFilesListProps> = ({ files, curre
key={`${file.path}:${index}`}
type="button"
className="relative flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1 typography-ui-label outline-hidden select-none text-left hover:bg-interactive-hover"
title={`Open ${file.path}`}
title={t('chat.changedFiles.actions.openFileTitle', { path: file.path })}
onClick={() => onOpenFile(file)}
>
<FileTypeIcon filePath={file.path} className="h-3.5 w-3.5 flex-shrink-0" />
@@ -40,6 +40,7 @@ import {
import { useSync } from '@/sync/use-sync';
import { usePlanDetection } from '@/hooks/usePlanDetection';
import { getAllSyncSessions } from '@/sync/sync-refs';
import { useI18n } from '@/lib/i18n';
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
const EMPTY_PERMISSIONS: PermissionRequest[] = [];
@@ -231,6 +232,7 @@ const HYDRATING_SKELETON_ITEMS: Array<{
];
export const ChatContainer: React.FC = () => {
const { t } = useI18n();
// Session UI state
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
@@ -432,11 +434,13 @@ export const ChatContainer: React.FC = () => {
size="xs"
onClick={handleReturnToParentSession}
className="absolute left-3 top-3 z-20 !font-normal bg-[var(--surface-background)]/95"
aria-label="Return to parent session"
title={parentSession.title?.trim() ? `Return to: ${parentSession.title}` : 'Return to parent session'}
aria-label={t('chat.container.returnToParent.aria')}
title={parentSession.title?.trim()
? t('chat.container.returnToParent.titleNamed', { title: parentSession.title })
: t('chat.container.returnToParent.title')}
>
<RiArrowLeftLine className="h-4 w-4" />
Parent
{t('chat.container.returnToParent.label')}
</Button>
) : null;
@@ -2,8 +2,10 @@ import React from 'react';
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useGlobalSyncStore } from '@/sync/global-sync-store';
import { useI18n } from '@/lib/i18n';
const ChatEmptyState: React.FC = () => {
const { t } = useI18n();
const { currentTheme } = useThemeSystem();
const initError = useGlobalSyncStore((s) => s.error);
@@ -14,13 +16,13 @@ const ChatEmptyState: React.FC = () => {
<OpenChamberLogo width={140} height={140} className="opacity-20" />
{initError ? (
<div className="flex flex-col items-center gap-2 max-w-md text-center px-4">
<span className="text-body-md font-medium text-destructive">OpenCode is not reachable</span>
<span className="text-body-md font-medium text-destructive">{t('chat.emptyState.opencodeUnreachable')}</span>
<span className="text-body-sm" style={{ color: textColor }}>
{initError.message}
</span>
</div>
) : (
<span className="text-body-md" style={{ color: textColor }}>Start a new chat</span>
<span className="text-body-md" style={{ color: textColor }}>{t('chat.emptyState.startNewChat')}</span>
)}
</div>
);
@@ -2,6 +2,7 @@ import React from 'react';
import { RiChat3Line, RiRestartLine } from '@remixicon/react';
import { Button } from '../ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { useI18n } from '@/lib/i18n';
interface ChatErrorBoundaryState {
hasError: boolean;
@@ -14,8 +15,21 @@ interface ChatErrorBoundaryProps {
sessionId?: string;
}
export class ChatErrorBoundary extends React.Component<ChatErrorBoundaryProps, ChatErrorBoundaryState> {
constructor(props: ChatErrorBoundaryProps) {
interface ChatErrorBoundaryTexts {
title: string;
description: string;
sessionLabel: string;
detailsSummary: string;
resetAction: string;
persistentHint: string;
}
interface ChatErrorBoundaryViewProps extends ChatErrorBoundaryProps {
texts: ChatErrorBoundaryTexts;
}
class ChatErrorBoundaryView extends React.Component<ChatErrorBoundaryViewProps, ChatErrorBoundaryState> {
constructor(props: ChatErrorBoundaryViewProps) {
super(props);
this.state = { hasError: false };
}
@@ -44,23 +58,23 @@ export class ChatErrorBoundary extends React.Component<ChatErrorBoundaryProps, C
<CardHeader className="text-center">
<CardTitle className="flex items-center justify-center gap-2 text-destructive">
<RiChat3Line className="h-5 w-5" />
Chat Error
{this.props.texts.title}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground text-center">
The chat interface encountered an error. This might be due to a temporary network issue or corrupted message data.
{this.props.texts.description}
</p>
{this.props.sessionId && (
<div className="text-xs text-muted-foreground text-center">
Session: {this.props.sessionId}
{this.props.texts.sessionLabel}: {this.props.sessionId}
</div>
)}
{this.state.error && (
<details className="text-xs font-mono bg-muted p-3 rounded">
<summary className="cursor-pointer hover:bg-interactive-hover/80">Error details</summary>
<summary className="cursor-pointer hover:bg-interactive-hover/80">{this.props.texts.detailsSummary}</summary>
<pre className="mt-2 overflow-x-auto">
{this.state.error.toString()}
</pre>
@@ -70,12 +84,12 @@ export class ChatErrorBoundary extends React.Component<ChatErrorBoundaryProps, C
<div className="flex gap-2">
<Button onClick={this.handleReset} variant="outline" className="flex-1">
<RiRestartLine className="h-4 w-4 mr-2" />
Reset Chat
{this.props.texts.resetAction}
</Button>
</div>
<div className="text-xs text-muted-foreground text-center">
If the problem persists, try refreshing the page.
{this.props.texts.persistentHint}
</div>
</CardContent>
</Card>
@@ -86,3 +100,20 @@ export class ChatErrorBoundary extends React.Component<ChatErrorBoundaryProps, C
return this.props.children;
}
}
export function ChatErrorBoundary(props: ChatErrorBoundaryProps) {
const { t } = useI18n();
return (
<ChatErrorBoundaryView
{...props}
texts={{
title: t('chat.errorBoundary.title'),
description: t('chat.errorBoundary.description'),
sessionLabel: t('chat.errorBoundary.sessionLabel'),
detailsSummary: t('chat.errorBoundary.detailsSummary'),
resetAction: t('chat.errorBoundary.resetAction'),
persistentHint: t('chat.errorBoundary.persistentHint'),
}}
/>
);
}
+63 -53
View File
@@ -72,6 +72,7 @@ import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
import { buildSessionTargetOptions } from '@/sync/session-worktree-contract';
import { usePermissionStore } from '@/stores/permissionStore';
import { extractGitChangedFiles } from './changedFiles';
import { useI18n } from '@/lib/i18n';
const MAX_VISIBLE_TEXTAREA_LINES = 8;
const EMPTY_QUEUE: QueuedMessage[] = [];
@@ -249,6 +250,7 @@ type ComposerAttachmentControlsProps = {
};
const ComposerAttachmentControls = React.memo(function ComposerAttachmentControls(props: ComposerAttachmentControlsProps) {
const { t } = useI18n();
const {
isMobile,
isVSCode,
@@ -280,8 +282,8 @@ const ComposerAttachmentControls = React.memo(function ComposerAttachmentControl
}
}}
onClick={handleOpenCommandMenu}
title="Commands"
aria-label="Commands"
title={t('chat.chatInput.actions.commands')}
aria-label={t('chat.chatInput.actions.commands')}
>
<RiCommandLine className={cn(iconSizeClass)} />
</button>
@@ -301,8 +303,8 @@ const ComposerAttachmentControls = React.memo(function ComposerAttachmentControl
type="button"
className={footerIconButtonClass}
onClick={handlePickLocalFiles}
title="Attach files"
aria-label="Attach files"
title={t('chat.chatInput.actions.attachFiles')}
aria-label={t('chat.chatInput.actions.attachFiles')}
>
<RiAttachment2 className={cn(iconSizeClass, 'text-current')} />
</button>
@@ -312,8 +314,8 @@ const ComposerAttachmentControls = React.memo(function ComposerAttachmentControl
<button
type="button"
className={footerIconButtonClass}
title="Add attachment"
aria-label="Add attachment"
title={t('chat.chatInput.actions.addAttachment')}
aria-label={t('chat.chatInput.actions.addAttachment')}
>
<RiAddCircleLine className={cn(iconSizeClass, 'text-current')} />
</button>
@@ -325,7 +327,7 @@ const ComposerAttachmentControls = React.memo(function ComposerAttachmentControl
}}
>
<RiAttachment2 />
Attach files
{t('chat.chatInput.actions.attachFiles')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
@@ -333,7 +335,7 @@ const ComposerAttachmentControls = React.memo(function ComposerAttachmentControl
}}
>
<RiGithubLine />
Link GitHub Issue
{t('chat.chatInput.actions.linkGithubIssue')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
@@ -341,7 +343,7 @@ const ComposerAttachmentControls = React.memo(function ComposerAttachmentControl
}}
>
<RiGitPullRequestLine />
Link GitHub PR
{t('chat.chatInput.actions.linkGithubPr')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -353,8 +355,8 @@ const ComposerAttachmentControls = React.memo(function ComposerAttachmentControl
type="button"
onClick={onOpenSettings}
className={footerIconButtonClass}
title="Model and agent settings"
aria-label="Model and agent settings"
title={t('chat.chatInput.actions.modelAgentSettings')}
aria-label={t('chat.chatInput.actions.modelAgentSettings')}
>
<RiAiAgentLine className={cn(iconSizeClass, 'text-current')} />
</button>
@@ -379,6 +381,7 @@ type PermissionAutoAcceptButtonProps = {
};
const PermissionAutoAcceptButton = React.memo(function PermissionAutoAcceptButton(props: PermissionAutoAcceptButtonProps) {
const { t } = useI18n();
const {
footerIconButtonClass,
iconSizeClass,
@@ -389,11 +392,11 @@ const PermissionAutoAcceptButton = React.memo(function PermissionAutoAcceptButto
} = props;
const ariaLabel = permissionAutoAcceptEnabled
? 'Disable permission auto-accept'
: 'Enable permission auto-accept';
? t('chat.chatInput.permissionAutoAccept.disable')
: t('chat.chatInput.permissionAutoAccept.enable');
const tooltipLabel = permissionAutoAcceptEnabled
? 'Permission auto-accept: on'
: 'Permission auto-accept: off';
? t('chat.chatInput.permissionAutoAccept.on')
: t('chat.chatInput.permissionAutoAccept.off');
const button = (
<button
@@ -450,6 +453,7 @@ type FocusModeButtonProps = {
const FocusModeButton = React.memo(function FocusModeButton(props: FocusModeButtonProps) {
const { footerIconButtonClass, iconSizeClass, isExpandedInput, onToggle } = props;
const { t } = useI18n();
return (
<Tooltip delayDuration={600}>
@@ -467,7 +471,7 @@ const FocusModeButton = React.memo(function FocusModeButton(props: FocusModeButt
event.preventDefault();
}}
onClick={onToggle}
aria-label="Toggle focus mode"
aria-label={t('chat.chatInput.focusMode.toggleAria')}
aria-pressed={isExpandedInput}
>
<RiFullscreenLine className={cn(iconSizeClass)} />
@@ -475,7 +479,7 @@ const FocusModeButton = React.memo(function FocusModeButton(props: FocusModeButt
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>
<div className="flex flex-col gap-0.5 text-center">
<span>Focus mode</span>
<span>{t('chat.chatInput.focusMode.label')}</span>
<span className="font-mono opacity-60">
{isMacOS() ? '⌘⇧E' : 'Ctrl+Shift+E'}
</span>
@@ -515,6 +519,7 @@ const ComposerActionButtons = React.memo(function ComposerActionButtons(props: C
onQueueMessage,
onAbort,
} = props;
const { t } = useI18n();
const sendButton = (
<button
@@ -534,7 +539,7 @@ const ComposerActionButtons = React.memo(function ComposerActionButtons(props: C
? 'text-primary hover:text-primary'
: 'opacity-30'
)}
aria-label="Send message"
aria-label={t('chat.chatInput.actions.sendMessageAria')}
>
<RiSendPlane2Line className={cn(sendIconSizeClass)} />
</button>
@@ -561,7 +566,7 @@ const ComposerActionButtons = React.memo(function ComposerActionButtons(props: C
'absolute z-20 bottom-full left-1/2 -translate-x-1/2 mb-1',
currentSessionId ? 'text-primary hover:text-primary' : 'opacity-30'
)}
aria-label="Queue message"
aria-label={t('chat.chatInput.actions.queueMessageAria')}
>
<RiSendPlane2Line className={cn(sendIconSizeClass, '-rotate-90')} />
</button>
@@ -573,7 +578,7 @@ const ComposerActionButtons = React.memo(function ComposerActionButtons(props: C
footerIconButtonClass,
'text-[var(--status-error)] hover:text-[var(--status-error)]'
)}
aria-label="Stop generating"
aria-label={t('chat.chatInput.actions.stopGeneratingAria')}
>
<StopIcon className={cn(stopIconSizeClass)} />
</button>
@@ -691,6 +696,7 @@ const loadConfirmedMentions = (sessionId: string | null): Set<string> => {
};
const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom }) => {
const { t } = useI18n();
// Track if we restored a draft on mount (for text selection)
const initialDraftRef = React.useRef<string | null>(null);
// Track initial session ID (captured at mount time for draft restoration)
@@ -1489,7 +1495,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
providerID: configState.currentProviderId || '',
});
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to compact session');
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.compactFailed'));
}
return;
}
@@ -1518,7 +1524,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
);
scrollToBottom?.({ instant: true, force: true });
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to generate summary');
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.summaryFailed'));
}
return;
}
@@ -1540,7 +1546,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
);
scrollToBottom?.({ instant: true, force: true });
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to review changes');
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.reviewFailed'));
}
return;
}
@@ -1603,7 +1609,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
normalized === 'failed to send message';
if (normalized.includes('payload too large') || normalized.includes('413') || normalized.includes('entity too large')) {
toast.error('Attachments are too large to send. Please try reducing the number or size of images.');
toast.error(t('chat.chatInput.toast.attachmentsTooLarge'));
if (allAttachments.length > 0) {
useInputStore.setState({ attachedFiles: allAttachments });
}
@@ -1613,7 +1619,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
if (isSoftNetworkError) {
if (allAttachments.length > 0) {
useInputStore.setState({ attachedFiles: allAttachments });
toast.error('Failed to send attachments. Try fewer files or smaller images.');
toast.error(t('chat.chatInput.toast.sendAttachmentsFailed'));
}
return;
}
@@ -1621,7 +1627,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
if (allAttachments.length > 0) {
useInputStore.setState({ attachedFiles: allAttachments });
}
toast.error(rawMessage || 'Message failed to send. Attachments restored.');
toast.error(rawMessage || t('chat.chatInput.toast.messageSendFailed'));
});
if (!isMobile) {
@@ -2329,7 +2335,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
await addAttachedFile(file);
} catch (error) {
console.error('Clipboard image attach failed', error);
toast.error(error instanceof Error ? error.message : 'Failed to attach image from clipboard');
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.clipboardAttachFailed'));
}
}
}, [addAttachedFile, currentSessionId, newSessionDraftOpen, insertTextAtSelection]);
@@ -2640,7 +2646,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}
setPendingInputText(mentions.join(' '), 'append-inline');
toast.success(`Added ${mentions.length} file mention${mentions.length > 1 ? 's' : ''}`);
toast.success(t('chat.chatInput.toast.addedFileMentions', { count: mentions.length }));
}, [normalizeDroppedPath, setPendingInputText, toProjectRelativeMentionPath]);
const handleDragEnter = (e: React.DragEvent) => {
@@ -2756,7 +2762,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
await addAttachedFile(file);
} catch (error) {
console.error('File attach failed', error);
toast.error(error instanceof Error ? error.message : 'Failed to attach file');
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.attachFileFailed'));
}
}
}
@@ -2864,7 +2870,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
await addAttachedFile(file);
} catch (error) {
console.error('Failed to attach dropped file:', path, error);
toast.error(`Failed to attach ${path.split(/[\\/]/).pop() || 'file'}`);
toast.error(t('chat.chatInput.toast.attachNamedFailed', {
name: path.split(/[\\/]/).pop() || t('chat.chatInput.fileFallback'),
}));
}
}
}
@@ -2898,7 +2906,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
await addAttachedFile(file);
} catch (error) {
console.error('File attach failed', error);
toast.error(error instanceof Error ? error.message : 'Failed to attach file');
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.attachFileFailed'));
}
}
}, [addAttachedFile]);
@@ -2914,7 +2922,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const summary = skipped
.map((s: { name?: string; reason?: string }) => `${s?.name || 'file'}: ${s?.reason || 'skipped'}`)
.join('\n');
toast.error(`Some files were skipped:\n${summary}`);
toast.error(t('chat.chatInput.toast.someFilesSkipped', { summary }));
}
const asFiles = picked
@@ -2943,7 +2951,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}
} catch (error) {
console.error('VS Code file pick failed', error);
toast.error(error instanceof Error ? error.message : 'Failed to pick files in VS Code');
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.vscodePickFailed'));
}
}, [attachFiles]);
@@ -3249,13 +3257,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const handlePermissionAutoAcceptToggle = React.useCallback(() => {
if (!permissionScopeSessionId) {
toast.error('Open a session first');
toast.error(t('chat.chatInput.toast.openSessionFirst'));
return;
}
const nextEnabled = !permissionAutoAcceptEnabled;
setSessionAutoAccept(permissionScopeSessionId, nextEnabled).catch(() => {
toast.error('Failed to toggle permission auto-accept');
toast.error(t('chat.chatInput.toast.togglePermissionAutoAcceptFailed'));
});
}, [permissionAutoAcceptEnabled, permissionScopeSessionId, setSessionAutoAccept]);
@@ -3311,7 +3319,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
borderColor: currentTheme?.colors?.interactive?.border,
}}
>
<span className="text-xs font-medium text-muted-foreground">Review comments:</span>
<span className="text-xs font-medium text-muted-foreground">{t('chat.chatInput.reviewComments')}</span>
<span className="text-xs font-semibold" style={{ color: currentTheme?.colors?.status?.info }}>
{draftCount}
</span>
@@ -3337,7 +3345,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
<span className="text-muted-foreground flex-shrink-0">
#{linkedIssue.number}
{linkedIssue.author && (
<span className="ml-1">by {linkedIssue.author.login}</span>
<span className="ml-1">{t('chat.chatInput.linked.byAuthor', { author: linkedIssue.author.login })}</span>
)}
</span>
<span className="text-foreground truncate">
@@ -3350,7 +3358,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors"
aria-label="Open issue in browser"
aria-label={t('chat.chatInput.linked.issue.openInBrowserAria')}
>
<RiExternalLinkLine className="h-4 w-4 text-muted-foreground" />
</a>
@@ -3360,7 +3368,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
setLinkedIssue(null);
}}
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
aria-label="Remove linked issue"
aria-label={t('chat.chatInput.linked.issue.removeAria')}
>
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
</span>
@@ -3383,9 +3391,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
/>
)}
<span className="text-muted-foreground flex-shrink-0">
PR #{linkedPr.number}
{t('chat.chatInput.linked.pr.number', { number: linkedPr.number })}
{linkedPr.author && (
<span className="ml-1">by {linkedPr.author.login}</span>
<span className="ml-1">{t('chat.chatInput.linked.byAuthor', { author: linkedPr.author.login })}</span>
)}
</span>
<span className="text-foreground truncate">
@@ -3401,7 +3409,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors"
aria-label="Open pull request in browser"
aria-label={t('chat.chatInput.linked.pr.openInBrowserAria')}
>
<RiExternalLinkLine className="h-4 w-4 text-muted-foreground" />
</a>
@@ -3411,7 +3419,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
setLinkedPr(null);
}}
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
aria-label="Remove linked pull request"
aria-label={t('chat.chatInput.linked.pr.removeAria')}
>
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
</span>
@@ -3458,13 +3466,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
>
<SelectValue>
{selectedDraftBranchLabel ?? 'Branch'}
{selectedDraftBranchLabel ?? t('chat.chatInput.branch')}
</SelectValue>
</SelectTrigger>
<SelectContent fitContent>
{projectRootBranchOption ? (
<SelectGroup>
<SelectLabel>Project root</SelectLabel>
<SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel>
<SelectItem key={projectRootBranchOption.value} value={projectRootBranchOption.value} className="max-w-[24rem] truncate">
{projectRootBranchOption.label}
</SelectItem>
@@ -3473,14 +3481,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
{projectRootBranchOption ? <SelectSeparator /> : null}
<SelectGroup>
<div className="flex items-center justify-between px-2 py-1.5">
<span className="text-muted-foreground typography-meta">Worktrees</span>
<span className="text-muted-foreground typography-meta">{t('chat.chatInput.worktrees')}</span>
<button
type="button"
className="text-muted-foreground typography-meta hover:text-foreground cursor-pointer"
onPointerDown={(e) => { e.stopPropagation(); }}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); void createWorktreeDraft(); }}
>
+ New
{t('chat.chatInput.worktreeNew')}
</button>
</div>
{worktreeBranchOptions.map((option) => (
@@ -3530,13 +3538,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
type="button"
className={iconButtonBaseClass}
onClick={() => handlePickLocalFiles()}
title="Attach files"
aria-label="Attach files"
title={t('chat.chatInput.actions.attachFiles')}
aria-label={t('chat.chatInput.actions.attachFiles')}
>
<RiAttachment2 className={cn(iconSizeClass, 'text-current')} />
</button>
</div>
<p className="mt-2 typography-ui-label text-muted-foreground">{isInternalDrag ? 'Drop to insert as mention' : 'Drop files here to attach'}</p>
<p className="mt-2 typography-ui-label text-muted-foreground">
{isInternalDrag ? t('chat.chatInput.drop.insertMention') : t('chat.chatInput.drop.attachFiles')}
</p>
</div>
</div>
)}
@@ -3666,9 +3676,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}}
placeholder={currentSessionId || newSessionDraftOpen
? inputMode === 'shell'
? "Enter shell command..."
: "@ for files/agents; / for commands; ! for shell"
: "Select or create a session to start chatting"}
? t('chat.chatInput.placeholder.shell')
: t('chat.chatInput.placeholder.chat')
: t('chat.chatInput.placeholder.selectSession')}
disabled={!currentSessionId && !newSessionDraftOpen}
autoCorrect={isMobile ? "on" : "off"}
autoCapitalize={isMobile ? "sentences" : "off"}
@@ -6,6 +6,7 @@ import { useSessionMessages } from '@/sync/sync-context';
import { useCommandsStore } from '@/stores/useCommandsStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useI18n } from '@/lib/i18n';
type CommandSource = 'openchamber' | 'opencode';
@@ -47,6 +48,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
onTabSelect,
style,
}, ref) => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessionMessages = useSessionMessages(currentSessionId ?? '');
const hasMessagesInCurrentSession = sessionMessages.length > 0;
@@ -107,23 +109,23 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
const builtInCommands: CommandInfo[] = [
...(hasSession && !hasMessagesInCurrentSession
? [{ id: 'openchamber:init', name: 'init', source: 'openchamber' as const, description: 'Create/update AGENTS.md file', isBuiltIn: true }]
? [{ id: 'openchamber:init', name: 'init', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.initDescription'), isBuiltIn: true }]
: []
),
...(hasSession // Show when session exists, not when hasMessages
? [
{ id: 'openchamber:undo', name: 'undo', source: 'openchamber' as const, description: 'Undo the last message', isBuiltIn: true },
{ id: 'openchamber:redo', name: 'redo', source: 'openchamber' as const, description: 'Redo previously undone messages', isBuiltIn: true },
{ id: 'openchamber:undo', name: 'undo', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.undoDescription'), isBuiltIn: true },
{ id: 'openchamber:redo', name: 'redo', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.redoDescription'), isBuiltIn: true },
]
: []
),
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: 'Compress session history using AI to reduce context size', isBuiltIn: true },
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.compactDescription'), isBuiltIn: true },
...(hasSession
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: 'Non-destructive session summary. Optional topic hint after the command.', isOpenChamber: true }]
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.summaryDescription'), isOpenChamber: true }]
: []
),
...(hasSession
? [{ id: 'openchamber:review', name: 'review', source: 'openchamber' as const, description: 'Review current workspace changes for high-signal issues only.', isOpenChamber: true }]
? [{ id: 'openchamber:review', name: 'review', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.reviewDescription'), isOpenChamber: true }]
: []
),
];
@@ -151,23 +153,23 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
const allowInitCommand = !hasMessagesInCurrentSession;
const builtInCommands: CommandInfo[] = [
...(hasSession && !hasMessagesInCurrentSession
? [{ id: 'openchamber:init', name: 'init', source: 'openchamber' as const, description: 'Create/update AGENTS.md file', isBuiltIn: true }]
? [{ id: 'openchamber:init', name: 'init', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.initDescription'), isBuiltIn: true }]
: []
),
...(hasSession // Show when session exists, not when hasMessages
? [
{ id: 'openchamber:undo', name: 'undo', source: 'openchamber' as const, description: 'Undo the last message', isBuiltIn: true },
{ id: 'openchamber:redo', name: 'redo', source: 'openchamber' as const, description: 'Redo previously undone messages', isBuiltIn: true },
{ id: 'openchamber:undo', name: 'undo', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.undoDescription'), isBuiltIn: true },
{ id: 'openchamber:redo', name: 'redo', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.redoDescription'), isBuiltIn: true },
]
: []
),
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: 'Compress session history using AI to reduce context size', isBuiltIn: true },
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.compactDescription'), isBuiltIn: true },
...(hasSession
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: 'Non-destructive session summary. Optional topic hint after the command.', isOpenChamber: true }]
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.summaryDescription'), isOpenChamber: true }]
: []
),
...(hasSession
? [{ id: 'openchamber:review', name: 'review', source: 'openchamber' as const, description: 'Review current workspace changes for high-signal issues only.', isOpenChamber: true }]
? [{ id: 'openchamber:review', name: 'review', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.reviewDescription'), isOpenChamber: true }]
: []
),
];
@@ -186,7 +188,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
};
loadCommands();
}, [searchQuery, hasMessagesInCurrentSession, hasSession, commandsWithMetadata, skills]);
}, [searchQuery, hasMessagesInCurrentSession, hasSession, commandsWithMetadata, skills, t]);
React.useEffect(() => {
setSelectedIndex(0);
@@ -266,9 +268,9 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
<div className="px-2 pt-2 pb-1 border-b border-border/60">
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-elevated)] p-1">
{([
{ id: 'commands' as const, label: 'Commands' },
{ id: 'agents' as const, label: 'Agents' },
{ id: 'files' as const, label: 'Files' },
{ id: 'commands' as const, label: t('chat.autocomplete.tabs.commands') },
{ id: 'agents' as const, label: t('chat.autocomplete.tabs.agents') },
{ id: 'files' as const, label: t('chat.autocomplete.tabs.files') },
]).map((tab) => (
<button
key={tab.id}
@@ -375,7 +377,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
<span className="typography-ui-label font-medium">/{command.name}</span>
{command.isSkill ? (
<span className="text-[10px] leading-none uppercase font-bold tracking-tight bg-[var(--status-info-background)] text-[var(--status-info)] border-[var(--status-info-border)] px-1.5 py-1 rounded border flex-shrink-0">
skill
{t('chat.commandAutocomplete.badge.skill')}
</span>
) : null}
{isOpenChamberBadge ? (
@@ -387,11 +389,11 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
borderColor: 'color-mix(in srgb, var(--primary-base) 28%, transparent)',
}}
>
openchamber
OpenChamber
</span>
) : isSystem ? (
<span className="text-[10px] leading-none uppercase font-bold tracking-tight bg-[var(--status-warning-background)] text-[var(--status-warning)] border-[var(--status-warning-border)] px-1.5 py-1 rounded border flex-shrink-0">
system
{t('chat.commandAutocomplete.badge.system')}
</span>
) : command.scope ? (
<span className={cn(
@@ -420,14 +422,14 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
})}
{commands.length === 0 && (
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
No commands found
{t('chat.commandAutocomplete.empty')}
</div>
)}
</div>
)}
</ScrollableOverlay>
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
navigate Enter select Esc close
{t('chat.autocomplete.keyboardHint')}
</div>
</div>
);
@@ -9,10 +9,12 @@ import { openExternalUrl } from '@/lib/url';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { useI18n } from '@/lib/i18n';
import type { ToolPopupContent } from './message/types';
export const FileAttachmentButton = memo(() => {
const { t } = useI18n();
const fileInputRef = useRef<HTMLInputElement>(null);
const addAttachedFile = useInputStore((state) => state.addAttachedFile);
const isMobile = useUIStore((state) => state.isMobile);
@@ -27,7 +29,7 @@ export const FileAttachmentButton = memo(() => {
await addAttachedFile(file);
} catch (error) {
console.error('File attach failed', error);
toast.error(error instanceof Error ? error.message : 'Failed to attach file');
toast.error(error instanceof Error ? error.message : t('chat.fileAttachment.toast.attachFailed'));
}
}
};
@@ -50,8 +52,8 @@ export const FileAttachmentButton = memo(() => {
const skipped = Array.isArray(data?.skipped) ? data.skipped : [];
if (skipped.length > 0) {
const summary = skipped.map((s: { name?: string; reason?: string }) => `${s?.name || 'file'}: ${s?.reason || 'skipped'}`).join('\n');
toast.error(`Some files were skipped:\n${summary}`);
const summary = skipped.map((s: { name?: string; reason?: string }) => `${s?.name || t('chat.fileAttachment.fileFallback')}: ${s?.reason || t('chat.fileAttachment.skippedFallback')}`).join('\n');
toast.error(t('chat.fileAttachment.toast.someFilesSkipped', { summary }));
}
const asFiles = picked
@@ -67,7 +69,7 @@ export const FileAttachmentButton = memo(() => {
bytes[i] = binary.charCodeAt(i);
}
const blob = new Blob([bytes], { type: mime });
return new File([blob], file.name || 'file', { type: mime });
return new File([blob], file.name || t('chat.fileAttachment.fileFallback'), { type: mime });
} catch (err) {
console.error('Failed to decode VS Code picked file', err);
return null;
@@ -80,7 +82,7 @@ export const FileAttachmentButton = memo(() => {
}
} catch (error) {
console.error('VS Code file pick failed', error);
toast.error(error instanceof Error ? error.message : 'Failed to pick files in VS Code');
toast.error(error instanceof Error ? error.message : t('chat.fileAttachment.toast.vscodePickFailed'));
}
};
@@ -103,13 +105,13 @@ export const FileAttachmentButton = memo(() => {
'hover:bg-muted text-muted-foreground',
buttonSizeClass
)}
aria-label="Attach files"
aria-label={t('chat.fileAttachment.actions.attachAria')}
>
<RiAttachment2 className={iconSizeClass} />
</button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Attach files</p>
<p>{t('chat.fileAttachment.actions.attach')}</p>
</TooltipContent>
</Tooltip>
</>
@@ -124,6 +126,7 @@ interface ImagePreviewProps {
}
const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => {
const { t } = useI18n();
const isLocalImagePreview =
file.source !== 'server' &&
file.mimeType.startsWith('image/') &&
@@ -163,7 +166,7 @@ const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => {
onRemove();
}}
className="flex items-center justify-center h-5 w-5 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
aria-label={`Remove ${displayName}`}
aria-label={t('chat.fileAttachment.actions.removeNamed', { name: displayName })}
>
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
</span>
@@ -182,8 +185,8 @@ const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => {
<button
onClick={onRemove}
className="absolute top-0.5 right-0.5 h-4 w-4 rounded-full bg-background/80 text-foreground hover:text-destructive flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
title="Remove image"
aria-label={`Remove ${displayName}`}
title={t('chat.fileAttachment.actions.removeImage')}
aria-label={t('chat.fileAttachment.actions.removeNamed', { name: displayName })}
>
<RiCloseLine className="h-2.5 w-2.5" />
</button>
@@ -199,6 +202,7 @@ interface FileChipProps {
}
const FileChip = memo(({ file, onRemove }: FileChipProps) => {
const { t } = useI18n();
const getFileExtension = (filename: string): string => {
const parts = filename.split('.');
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
@@ -245,7 +249,7 @@ const FileChip = memo(({ file, onRemove }: FileChipProps) => {
onRemove();
}}
className="flex items-center justify-center h-5 w-5 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
aria-label={`Remove ${displayName}`}
aria-label={t('chat.fileAttachment.actions.removeNamed', { name: displayName })}
>
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
</span>
@@ -11,6 +11,7 @@ import type { ProjectFileSearchHit } from '@/lib/opencode/client';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { useI18n } from '@/lib/i18n';
type FileInfo = ProjectFileSearchHit;
type AgentInfo = {
@@ -48,6 +49,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
onTabSelect,
style,
}, ref) => {
const { t } = useI18n();
const currentDirectory = useChatSearchDirectory() ?? '';
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const activeProjectPath = useProjectsStore(
@@ -446,6 +448,12 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
}
};
const tabs = React.useMemo(() => ([
{ id: 'commands' as const, label: t('chat.autocomplete.tabs.commands') },
{ id: 'agents' as const, label: t('chat.autocomplete.tabs.agents') },
{ id: 'files' as const, label: t('chat.autocomplete.tabs.files') },
]), [t]);
return (
<div
ref={containerRef}
@@ -455,11 +463,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
{showTabs ? (
<div className="px-2 pt-2 pb-1 border-b border-border/60">
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-elevated)] p-1">
{([
{ id: 'commands' as const, label: 'Commands' },
{ id: 'agents' as const, label: 'Agents' },
{ id: 'files' as const, label: 'Files' },
]).map((tab) => (
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
@@ -523,7 +527,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
})}
{visibleAgents.length === 2 && normalizedSearchQuery.length === 0 && agents.length > 2 && (
<div className="px-3 py-1 typography-meta text-muted-foreground">
Type to search more agents
{t('chat.fileMentionAutocomplete.searchMoreAgents')}
</div>
)}
{visibleAgents.length > 0 && (visibleDirectories.length > 0 || visibleRecentFiles.length > 0 || visibleFiles.length > 0) && (
@@ -664,14 +668,14 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
})}
{visibleFiles.length === 0 && visibleDirectories.length === 0 && visibleRecentFiles.length === 0 && visibleAgents.length === 0 && (
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
No matches found
{t('chat.fileMentionAutocomplete.empty')}
</div>
)}
</div>
)}
</ScrollableOverlay>
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
navigate Enter select Esc close
{t('chat.autocomplete.keyboardHint')}
</div>
</div>
);
@@ -15,6 +15,7 @@ import { RiFileCopyLine, RiCheckLine, RiDownloadLine } from '@remixicon/react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { toast } from '@/components/ui';
import { copyTextToClipboard } from '@/lib/clipboard';
import { useI18n } from '@/lib/i18n';
import { isExternalHttpUrl, openExternalUrl } from '@/lib/url';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
@@ -176,6 +177,7 @@ const downloadFile = (filename: string, content: string, mimeType: string) => {
// Table copy button with dropdown
const TableCopyButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | null> }> = ({ tableRef }) => {
const { t } = useI18n();
const [copied, setCopied] = React.useState(false);
const [showMenu, setShowMenu] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);
@@ -224,7 +226,7 @@ const TableCopyButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | nul
<button
onClick={() => setShowMenu(!showMenu)}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title="Copy table"
title={t('markdownRenderer.table.actions.copyTitle')}
>
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
</button>
@@ -250,6 +252,7 @@ const TableCopyButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | nul
// Table download button with dropdown
const TableDownloadButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | null> }> = ({ tableRef }) => {
const { t } = useI18n();
const [showMenu, setShowMenu] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);
@@ -273,7 +276,7 @@ const TableDownloadButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement |
const mimeType = format === 'csv' ? 'text/csv' : 'text/markdown';
downloadFile(filename, content, mimeType);
setShowMenu(false);
toast.success(`Table downloaded as ${format.toUpperCase()}`);
toast.success(t('markdownRenderer.table.toast.downloadedAsFormat', { format: format.toUpperCase() }));
};
return (
@@ -281,7 +284,7 @@ const TableDownloadButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement |
<button
onClick={() => setShowMenu(!showMenu)}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title="Download table"
title={t('markdownRenderer.table.actions.downloadTitle')}
>
<RiDownloadLine className="size-3.5" />
</button>
@@ -325,6 +328,7 @@ const TableWrapper: React.FC<{ children?: React.ReactNode; className?: string }>
};
const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ source, mode }) => {
const { t } = useI18n();
const currentTheme = useCurrentMermaidTheme();
const { isMobile } = useDeviceInfo();
const [copied, setCopied] = React.useState(false);
@@ -393,7 +397,7 @@ const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ sou
setDownloaded(true);
setTimeout(() => setDownloaded(false), 2000);
} catch {
toast.error('Failed to download diagram');
toast.error(t('markdownRenderer.mermaid.toast.downloadFailed'));
}
};
@@ -414,7 +418,7 @@ const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ sou
<button
onClick={() => handleCopyAscii(asciiText)}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title="Copy"
title={t('markdownRenderer.mermaid.actions.copyTitle')}
>
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
</button>
@@ -438,7 +442,7 @@ const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ sou
<button
onClick={() => handleCopyAscii(source)}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title="Copy"
title={t('markdownRenderer.mermaid.actions.copyTitle')}
>
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
</button>
@@ -461,14 +465,14 @@ const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ sou
<button
onClick={handleCopyMermaidSource}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title="Copy source"
title={t('markdownRenderer.mermaid.actions.copySourceTitle')}
>
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
</button>
<button
onClick={handleDownloadSvg}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title="Download SVG"
title={t('markdownRenderer.mermaid.actions.downloadSvgTitle')}
>
{downloaded ? <RiCheckLine className="size-3.5" /> : <RiDownloadLine className="size-3.5" />}
</button>
@@ -55,6 +55,7 @@ import { useDrawerSwipe } from '@/hooks/useDrawerSwipe';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useNotificationStore } from '@/sync/notification-store';
import { useI18n } from '@/lib/i18n';
interface MobileSessionStatusBarProps {
onSessionSwitch?: (sessionId: string) => void;
@@ -745,6 +746,7 @@ function ProjectEditPanel({
onDelete,
homeDirectory,
}: ProjectEditPanelProps) {
const { t } = useI18n();
const [localProjects, setLocalProjects] = React.useState(projects);
React.useEffect(() => {
@@ -797,10 +799,10 @@ function ProjectEditPanel({
<MobileOverlayPanel
open={isOpen}
onClose={onClose}
title="Edit Projects"
title={t('chat.mobileStatus.editProjects.title')}
footer={
<p className="text-xs text-[var(--surface-mutedForeground)] text-center">
Drag items to reorder, or use arrows to move. Tap edit to change details.
{t('chat.mobileStatus.editProjects.footer')}
</p>
}
>
@@ -832,7 +834,7 @@ function ProjectEditPanel({
{localProjects.length === 0 && (
<div className="text-center py-8 text-[var(--surface-mutedForeground)]">
No projects to edit
{t('chat.mobileStatus.editProjects.empty')}
</div>
)}
</div>
@@ -958,6 +960,7 @@ function ProjectBar({
onRemoveProject,
homeDirectory
}: ProjectBarProps) {
const { t } = useI18n();
const scrollRef = React.useRef<HTMLDivElement>(null);
const [editPanelOpen, setEditPanelOpen] = React.useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
@@ -1012,12 +1015,12 @@ function ProjectBar({
if (projects.length === 0) {
return (
<div className="flex items-center gap-2 px-2 py-1 border-b border-[var(--interactive-border)] bg-transparent">
<span className="text-[11px] text-[var(--surface-mutedForeground)]">No projects</span>
<span className="text-[11px] text-[var(--surface-mutedForeground)]">{t('chat.mobileStatus.projects.empty')}</span>
<button
type="button"
onClick={onAddProject}
className="flex items-center justify-center !py-1.5 px-2 rounded-md border border-[var(--primary-base)]/60 bg-[var(--primary-base)]/5 text-[var(--primary-base)]/80 hover:text-[var(--primary-base)] hover:bg-[var(--primary-base)]/10 !min-h-0"
aria-label="Add project"
aria-label={t('chat.mobileStatus.projects.addAria')}
>
<RiAddLine className="h-3 w-3" />
</button>
@@ -1093,7 +1096,7 @@ function ProjectBar({
type="button"
onClick={onAddProject}
className="flex items-center justify-center !py-1.5 px-2 rounded-md border border-[var(--primary-base)]/60 bg-[var(--primary-base)]/5 text-[var(--primary-base)]/80 hover:text-[var(--primary-base)] hover:bg-[var(--primary-base)]/10 shrink-0 !min-h-0"
aria-label="Add project"
aria-label={t('chat.mobileStatus.projects.addAria')}
>
<RiAddLine className="h-3.5 w-3.5" />
</button>
@@ -1102,17 +1105,17 @@ function ProjectBar({
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Remove Project</DialogTitle>
<DialogTitle>{t('chat.mobileStatus.projects.removeTitle')}</DialogTitle>
<DialogDescription>
Are you sure you want to remove <span className="font-medium text-foreground">{projectToDelete?.label || formatDirectoryName(projectToDelete?.path || '', homeDirectory)}</span>?
{t('chat.mobileStatus.projects.removeDescriptionPrefix')} <span className="font-medium text-foreground">{projectToDelete?.label || formatDirectoryName(projectToDelete?.path || '', homeDirectory)}</span>?
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2">
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Cancel
{t('chat.mobileStatus.projects.cancel')}
</Button>
<Button variant="destructive" onClick={handleConfirmDelete}>
Remove
{t('chat.mobileStatus.projects.remove')}
</Button>
</DialogFooter>
</DialogContent>
@@ -1176,6 +1179,7 @@ function CollapsedView({
contextUsage: SessionContextUsage | null;
childIndicators?: Array<{ session: Session; isRunning: boolean }>;
}) {
const { t } = useI18n();
const { handleTouchStart, handleTouchMove, handleTouchEnd } = useDrawerSwipe();
return (
@@ -1219,7 +1223,7 @@ function CollapsedView({
}}
className="flex items-center gap-0.5 px-2 py-1 text-[12px] leading-tight !min-h-0 rounded border border-[var(--primary-base)]/60 bg-[var(--primary-base)]/5 text-[var(--primary-base)]/80 hover:text-[var(--primary-base)] hover:bg-[var(--primary-base)]/10 self-center"
>
New
{t('chat.mobileStatus.new')}
</button>
</div>
</div>
@@ -1283,6 +1287,7 @@ function ExpandedView({
homeDirectory: string | null;
childIndicators?: Array<{ session: Session; isRunning: boolean }>;
}) {
const { t } = useI18n();
const containerRef = React.useRef<HTMLDivElement>(null);
const [collapsedHeight, setCollapsedHeight] = React.useState<number | null>(null);
const [hasMeasured, setHasMeasured] = React.useState(false);
@@ -1376,7 +1381,7 @@ function ExpandedView({
}}
className="flex items-center gap-0.5 px-2 py-1 text-[12px] leading-tight !min-h-0 rounded border border-[var(--primary-base)]/60 bg-[var(--primary-base)]/5 text-[var(--primary-base)]/80 hover:text-[var(--primary-base)] hover:bg-[var(--primary-base)]/10 self-start"
>
New
{t('chat.mobileStatus.new')}
</button>
</div>
</div>
@@ -1400,7 +1405,7 @@ function ExpandedView({
>
{displaySessions.length === 0 ? (
<div className="flex items-center justify-center py-3 text-[11px] text-[var(--surface-mutedForeground)]">
<span>No sessions in this project</span>
<span>{t('chat.mobileStatus.noSessionsInProject')}</span>
</div>
) : (
displaySessions.map((session) => (
@@ -1424,6 +1429,7 @@ function ExpandedView({
export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
onSessionSwitch,
}) => {
const { t } = useI18n();
const { currentTheme } = useThemeSystem();
const sessions = useSessions();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
@@ -1457,7 +1463,7 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
const currentSession = sessions.find((s) => s.id === currentSessionId);
const currentSessionTitle = currentSession
? getSessionTitle(currentSession)
: '← Swipe here to open sidebars →';
: t('chat.mobileStatus.swipeHint');
// Calculate current session's child indicators
const currentSessionWithStatus = sortedSessions.find((s) => s.id === currentSessionId);
@@ -1522,19 +1528,19 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
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('chat.mobileStatus.toast.addProjectFailed'), {
description: t('chat.mobileStatus.toast.selectValidDirectory'),
});
}
} else if (result.error && result.error !== 'Directory selection cancelled') {
toast.error('Failed to select directory', {
toast.error(t('chat.mobileStatus.toast.selectDirectoryFailed'), {
description: result.error,
});
}
})
.catch((error) => {
console.error('Failed to select directory:', error);
toast.error('Failed to select directory');
toast.error(t('chat.mobileStatus.toast.selectDirectoryFailed'));
});
};
+134 -85
View File
@@ -55,6 +55,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { useModelLists } from '@/hooks/useModelLists';
import { useIsTextTruncated } from '@/hooks/useIsTextTruncated';
import type { MobileControlsPanel } from './mobileControlsUtils';
import { useI18n } from '@/lib/i18n';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type IconComponent = ComponentType<any>;
@@ -293,6 +294,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
onMobilePanelSelection,
onAgentPanelSelection,
}) => {
const { t } = useI18n();
const providers = useConfigStore((state) => state.providers);
const currentProviderId = useConfigStore((state) => state.currentProviderId);
const currentModelId = useConfigStore((state) => state.currentModelId);
@@ -545,9 +547,29 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const currentMetadata =
currentProviderId && currentModelId ? getModelMetadata(currentProviderId, currentModelId) : undefined;
const currentCapabilityIcons = getCapabilityIcons(currentMetadata);
const inputModalityIcons = getModalityIcons(currentMetadata, 'input');
const outputModalityIcons = getModalityIcons(currentMetadata, 'output');
const localizeMetaLabel = React.useCallback((label: string) => {
if (label === 'Tool calling') return t('chat.modelControls.capability.toolCalling');
if (label === 'Reasoning') return t('chat.modelControls.capability.reasoning');
if (label === 'Text') return t('chat.modelControls.modality.text');
if (label === 'Image') return t('chat.modelControls.modality.image');
if (label === 'Video') return t('chat.modelControls.modality.video');
if (label === 'Audio') return t('chat.modelControls.modality.audio');
if (label === 'PDF') return t('chat.modelControls.modality.pdf');
return label;
}, [t]);
const currentCapabilityIcons = React.useMemo(
() => getCapabilityIcons(currentMetadata).map((icon) => ({ ...icon, label: localizeMetaLabel(icon.label) })),
[currentMetadata, localizeMetaLabel],
);
const inputModalityIcons = React.useMemo(
() => getModalityIcons(currentMetadata, 'input').map((icon) => ({ ...icon, label: localizeMetaLabel(icon.label) })),
[currentMetadata, localizeMetaLabel],
);
const outputModalityIcons = React.useMemo(
() => getModalityIcons(currentMetadata, 'output').map((icon) => ({ ...icon, label: localizeMetaLabel(icon.label) })),
[currentMetadata, localizeMetaLabel],
);
// Compute from current model each render to avoid stale variants
// in draft/session transitions.
@@ -1148,14 +1170,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<div className="flex flex-col gap-1.5">
{}
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="typography-micro text-muted-foreground mb-0.5">Provider</div>
<div className="typography-micro text-muted-foreground mb-0.5">{t('chat.modelControls.provider')}</div>
<div className="typography-meta text-foreground font-medium">{getProviderDisplayName()}</div>
</div>
{}
{currentCapabilityIcons.length > 0 && (
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="typography-micro text-muted-foreground mb-1">Capabilities</div>
<div className="typography-micro text-muted-foreground mb-1">{t('chat.modelControls.capabilities')}</div>
<div className="flex flex-wrap gap-1.5">
{currentCapabilityIcons.map(({ key, icon, label }) => (
<div key={key} className="flex items-center gap-1.5">
@@ -1170,11 +1192,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
{}
{(inputModalityIcons.length > 0 || outputModalityIcons.length > 0) && (
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="typography-micro text-muted-foreground mb-1">Modalities</div>
<div className="typography-micro text-muted-foreground mb-1">{t('chat.modelControls.modalities')}</div>
<div className="flex flex-col gap-1">
{inputModalityIcons.length > 0 && (
<div className="flex items-center gap-2">
<span className="typography-meta text-muted-foreground/80 w-12">Input</span>
<span className="typography-meta text-muted-foreground/80 w-12">{t('chat.modelControls.input')}</span>
<div className="flex gap-1">
{inputModalityIcons.map(({ key, icon, label }) => renderIconBadge(icon, `${label} input`, `input-${key}`))}
</div>
@@ -1182,7 +1204,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
)}
{outputModalityIcons.length > 0 && (
<div className="flex items-center gap-2">
<span className="typography-meta text-muted-foreground/80 w-12">Output</span>
<span className="typography-meta text-muted-foreground/80 w-12">{t('chat.modelControls.output')}</span>
<div className="flex gap-1">
{outputModalityIcons.map(({ key, icon, label }) => renderIconBadge(icon, `${label} output`, `output-${key}`))}
</div>
@@ -1194,14 +1216,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
{}
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="typography-micro text-muted-foreground mb-1">Limits</div>
<div className="typography-micro text-muted-foreground mb-1">{t('chat.modelControls.limits')}</div>
<div className="flex flex-col gap-0.5">
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Context</span>
<span className="typography-meta text-muted-foreground/80">{t('chat.modelControls.context')}</span>
<span className="typography-meta font-medium text-foreground">{formatTokens(currentMetadata?.limit?.context)}</span>
</div>
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Output</span>
<span className="typography-meta text-muted-foreground/80">{t('chat.modelControls.output')}</span>
<span className="typography-meta font-medium text-foreground">{formatTokens(currentMetadata?.limit?.output)}</span>
</div>
</div>
@@ -1209,14 +1231,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
{}
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="typography-micro text-muted-foreground mb-1">Metadata</div>
<div className="typography-micro text-muted-foreground mb-1">{t('chat.modelControls.metadata')}</div>
<div className="flex flex-col gap-0.5">
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Knowledge</span>
<span className="typography-meta text-muted-foreground/80">{t('chat.modelControls.knowledge')}</span>
<span className="typography-meta font-medium text-foreground">{formatKnowledge(currentMetadata?.knowledge)}</span>
</div>
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Release</span>
<span className="typography-meta text-muted-foreground/80">{t('chat.modelControls.release')}</span>
<span className="typography-meta font-medium text-foreground">{formatDate(currentMetadata?.release_date)}</span>
</div>
</div>
@@ -1239,12 +1261,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const action = resolveWildcardPermissionAction(rules, permissionName) ?? 'ask';
if (hasCustom) {
return { mode: 'ask', label: 'Custom' };
return { mode: 'ask', label: t('chat.modelControls.permissionLabel.custom') };
}
if (action === 'allow') return { mode: 'allow', label: 'Allow' };
if (action === 'deny') return { mode: 'deny', label: 'Deny' };
return { mode: 'ask', label: 'Ask' };
if (action === 'allow') return { mode: 'allow', label: t('chat.modelControls.permissionLabel.allow') };
if (action === 'deny') return { mode: 'deny', label: t('chat.modelControls.permissionLabel.deny') };
return { mode: 'ask', label: t('chat.modelControls.permissionLabel.ask') };
};
const editPermissionSummary = summarizePermission('edit');
@@ -1267,16 +1289,22 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
{}
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="typography-micro text-muted-foreground mb-0.5">Mode</div>
<div className="typography-micro text-muted-foreground mb-0.5">{t('chat.modelControls.mode')}</div>
<div className="typography-meta text-foreground font-medium">
{currentAgent.mode === 'primary' ? 'Primary' : currentAgent.mode === 'subagent' ? 'Subagent' : currentAgent.mode === 'all' ? 'All' : '—'}
{currentAgent.mode === 'primary'
? t('chat.modelControls.modeValue.primary')
: currentAgent.mode === 'subagent'
? t('chat.modelControls.modeValue.subagent')
: currentAgent.mode === 'all'
? t('chat.modelControls.modeValue.all')
: t('chat.modelControls.modeValue.none')}
</div>
</div>
{}
{(hasModelConfig || hasTemperatureOrTopP) && (
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="typography-micro text-muted-foreground mb-1">Model</div>
<div className="typography-micro text-muted-foreground mb-1">{t('chat.modelControls.model')}</div>
{hasModelConfig && (
<div className="typography-meta text-foreground font-medium mb-1">
{currentAgent.model!.providerID} / {currentAgent.model!.modelID}
@@ -1286,13 +1314,13 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<div className="flex flex-col gap-0.5">
{currentAgent.temperature !== undefined && (
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Temperature</span>
<span className="typography-meta text-muted-foreground/80">{t('chat.modelControls.temperature')}</span>
<span className="typography-meta font-medium text-foreground">{currentAgent.temperature}</span>
</div>
)}
{currentAgent.topP !== undefined && (
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Top P</span>
<span className="typography-meta text-muted-foreground/80">{t('chat.modelControls.topP')}</span>
<span className="typography-meta font-medium text-foreground">{currentAgent.topP}</span>
</div>
)}
@@ -1304,10 +1332,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
{}
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="typography-micro text-muted-foreground mb-1">Permissions</div>
<div className="typography-micro text-muted-foreground mb-1">{t('chat.modelControls.permissions')}</div>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Edit</span>
<span className="typography-meta text-muted-foreground/80">{t('chat.modelControls.edit')}</span>
<div className="flex items-center gap-1.5">
{renderEditModeIcon(editPermissionSummary.mode, 'h-3.5 w-3.5')}
<span className="typography-meta font-medium text-foreground">
@@ -1316,7 +1344,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
</div>
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Bash</span>
<span className="typography-meta text-muted-foreground/80">{t('chat.modelControls.bash')}</span>
<div className="flex items-center gap-1.5">
{renderEditModeIcon(bashPermissionSummary.mode, 'h-3.5 w-3.5')}
<span className="typography-meta font-medium text-foreground">
@@ -1325,7 +1353,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
</div>
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">WebFetch</span>
<span className="typography-meta text-muted-foreground/80">{t('chat.modelControls.webFetch')}</span>
<div className="flex items-center gap-1.5">
{renderEditModeIcon(webfetchPermissionSummary.mode, 'h-3.5 w-3.5')}
<span className="typography-meta font-medium text-foreground">
@@ -1340,7 +1368,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
{hasCustomPrompt && (
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Custom Prompt</span>
<span className="typography-meta text-muted-foreground/80">{t('chat.modelControls.customPrompt')}</span>
<RiCheckboxCircleLine className="h-4 w-4 text-foreground" />
</div>
</div>
@@ -1408,7 +1436,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<MobileOverlayPanel
open={activeMobilePanel === 'model'}
onClose={closeMobilePanel}
title="Select model"
title={t('chat.modelControls.selectModel')}
>
<div className="flex flex-col gap-2">
<div>
@@ -1417,7 +1445,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<Input
value={mobileModelQuery}
onChange={(event) => setMobileModelQuery(event.target.value)}
placeholder="Search providers or models"
placeholder={t('chat.modelControls.searchProvidersOrModels')}
className="pl-7 h-9 rounded-xl border-border/40 bg-[var(--surface-elevated)] typography-meta"
/>
{mobileModelQuery && (
@@ -1425,7 +1453,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
type="button"
onClick={() => setMobileModelQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label="Clear search"
aria-label={t('chat.modelControls.clearSearch')}
>
<RiCloseCircleLine className="h-4 w-4" />
</button>
@@ -1444,7 +1472,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<div className="rounded-xl border border-border/40 bg-[var(--surface-elevated)] overflow-hidden">
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
<RiStarFill className="h-3 w-3 inline-block mr-1.5 text-primary" />
Favorites
{t('chat.modelControls.favorites')}
</div>
<div className="flex flex-col border-t border-border/30">
{favoriteModelsList.map(({ model, providerID, modelID }) => {
@@ -1490,7 +1518,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<div className="rounded-xl border border-border/40 bg-[var(--surface-elevated)] overflow-hidden">
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
<RiTimeLine className="h-3 w-3 inline-block mr-1.5" />
Recent
{t('chat.modelControls.recent')}
</div>
<div className="flex flex-col border-t border-border/30">
{recentModelsList.map(({ model, providerID, modelID }) => {
@@ -1556,7 +1584,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
{provider.name}
</span>
{isActiveProvider && (
<span className="typography-micro text-primary/80">Current</span>
<span className="typography-micro text-primary/80">{t('chat.modelControls.current')}</span>
)}
</div>
{isExpanded ? (
@@ -1571,8 +1599,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
{providerModels.map((model: ProviderModel) => {
const isSelected = isActiveProvider && model.id === currentModelId;
const metadata = getModelMetadata(provider.id, model.id!);
const capabilityIcons = getCapabilityIcons(metadata).slice(0, 3);
const inputIcons = getModalityIcons(metadata, 'input');
const capabilityIcons = getCapabilityIcons(metadata).slice(0, 3).map((icon) => ({ ...icon, label: localizeMetaLabel(icon.label) }));
const inputIcons = getModalityIcons(metadata, 'input').map((icon) => ({ ...icon, label: localizeMetaLabel(icon.label) }));
return (
<div
@@ -1636,8 +1664,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
? "text-primary"
: "text-muted-foreground"
)}
aria-label={isFavoriteModel(provider.id as string, model.id as string) ? "Unfavorite" : "Favorite"}
title={isFavoriteModel(provider.id as string, model.id as string) ? "Remove from favorites" : "Add to favorites"}
aria-label={isFavoriteModel(provider.id as string, model.id as string)
? t('chat.modelControls.unfavoriteAria')
: t('chat.modelControls.favoriteAria')}
title={isFavoriteModel(provider.id as string, model.id as string)
? t('chat.modelControls.removeFromFavorites')
: t('chat.modelControls.addToFavorites')}
>
{isFavoriteModel(provider.id as string, model.id as string) ? (
<RiStarFill className="h-4 w-4" />
@@ -1682,7 +1714,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<MobileOverlayPanel
open={activeMobilePanel === 'variant'}
onClose={closeMobilePanel}
title="Thinking"
title={t('chat.modelControls.thinking')}
>
<div className="flex flex-col gap-1.5">
<button
@@ -1694,7 +1726,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
)}
onClick={() => handleSelect(undefined)}
>
<span className="typography-meta font-medium text-foreground">Default</span>
<span className="typography-meta font-medium text-foreground">{t('chat.modelControls.default')}</span>
{isDefault && <RiCheckLine className="h-4 w-4 text-primary flex-shrink-0" />}
</button>
@@ -1730,7 +1762,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<MobileOverlayPanel
open={activeMobilePanel === 'agent'}
onClose={closeMobilePanel}
title="Select agent"
title={t('chat.modelControls.selectAgent')}
contentMaxHeightClassName="max-h-[min(52dvh,360px)]"
>
<div className="flex flex-col gap-2">
@@ -1788,22 +1820,22 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<span className="typography-meta text-muted-foreground">{getProviderDisplayName()}</span>
</div>
<div className="flex flex-col gap-1.5">
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Capabilities</span>
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">{t('chat.modelControls.capabilities')}</span>
<div className="flex flex-wrap items-center gap-1.5">
{currentCapabilityIcons.length > 0 ? (
currentCapabilityIcons.map(({ key, icon, label }) =>
renderIconBadge(icon, label, `cap-${key}`)
)
) : (
<span className="typography-meta text-muted-foreground"></span>
<span className="typography-meta text-muted-foreground">{t('chat.modelControls.modeValue.none')}</span>
)}
</div>
</div>
<div className="flex flex-col gap-1.5">
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Modalities</span>
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">{t('chat.modelControls.modalities')}</span>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-3">
<span className="typography-meta font-medium text-muted-foreground/80">Input</span>
<span className="typography-meta font-medium text-muted-foreground/80">{t('chat.modelControls.input')}</span>
<div className="flex items-center gap-1.5">
{inputModalityIcons.length > 0
? inputModalityIcons.map(({ key, icon, label }) =>
@@ -1813,7 +1845,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
</div>
<div className="flex items-center justify-between gap-3">
<span className="typography-meta font-medium text-muted-foreground/80">Output</span>
<span className="typography-meta font-medium text-muted-foreground/80">{t('chat.modelControls.output')}</span>
<div className="flex items-center gap-1.5">
{outputModalityIcons.length > 0
? outputModalityIcons.map(({ key, icon, label }) =>
@@ -1825,7 +1857,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
</div>
<div className="flex flex-col gap-1.5">
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Cost ($/1M tokens)</span>
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">{t('chat.modelControls.costPerMillion')}</span>
{costRows.map((row) => (
<div key={row.label} className="flex items-center justify-between gap-3">
<span className="typography-meta font-medium text-muted-foreground/80">{row.label}</span>
@@ -1834,7 +1866,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
))}
</div>
<div className="flex flex-col gap-1.5">
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Limits</span>
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">{t('chat.modelControls.limits')}</span>
{limitRows.map((row) => (
<div key={row.label} className="flex items-center justify-between gap-3">
<span className="typography-meta font-medium text-muted-foreground/80">{row.label}</span>
@@ -1843,19 +1875,19 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
))}
</div>
<div className="flex flex-col gap-1.5">
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Metadata</span>
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">{t('chat.modelControls.metadata')}</span>
<div className="flex items-center justify-between gap-3">
<span className="typography-meta font-medium text-muted-foreground/80">Knowledge</span>
<span className="typography-meta font-medium text-muted-foreground/80">{t('chat.modelControls.knowledge')}</span>
<span className="typography-meta font-medium text-foreground">{formatKnowledge(currentMetadata.knowledge)}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="typography-meta font-medium text-muted-foreground/80">Release</span>
<span className="typography-meta font-medium text-muted-foreground/80">{t('chat.modelControls.release')}</span>
<span className="typography-meta font-medium text-foreground">{formatDate(currentMetadata.release_date)}</span>
</div>
</div>
</div>
) : (
<div className="min-w-[200px] typography-meta text-muted-foreground">Model metadata unavailable.</div>
<div className="min-w-[200px] typography-meta text-muted-foreground">{t('chat.modelControls.metadataUnavailable')}</div>
)}
</TooltipContent>
);
@@ -1872,11 +1904,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const metadata = getModelMetadata(providerID, modelID);
const capabilityIcons = getCapabilityIcons(metadata).map((icon) => ({
...icon,
label: localizeMetaLabel(icon.label),
id: `cap-${icon.key}`,
}));
const modalityIcons = [
...getModalityIcons(metadata, 'input'),
...getModalityIcons(metadata, 'output'),
...getModalityIcons(metadata, 'input').map((icon) => ({ ...icon, label: localizeMetaLabel(icon.label) })),
...getModalityIcons(metadata, 'output').map((icon) => ({ ...icon, label: localizeMetaLabel(icon.label) })),
];
const uniqueModalityIcons = Array.from(
new Map(modalityIcons.map((icon) => [icon.key, icon])).values()
@@ -2006,8 +2039,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
"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('chat.modelControls.unfavoriteAria')
: t('chat.modelControls.favoriteAria')}
title={isFavorite
? t('chat.modelControls.removeFromFavorites')
: t('chat.modelControls.addToFavorites')}
>
{isFavorite ? (
<RiStarFill className="h-3.5 w-3.5" />
@@ -2229,7 +2266,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<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('chat.modelControls.searchModels')}
value={desktopModelQuery}
onChange={(e) => setDesktopModelQuery(e.target.value)}
onKeyDown={handleModelKeyDown}
@@ -2260,14 +2297,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<span className="flex h-4 w-4 items-center justify-center text-muted-foreground">
<RiAddLine className="h-4 w-4 -mr-0.5" />
</span>
<span className="font-medium text-foreground">Add new provider</span>
<span className="font-medium text-foreground">{t('chat.modelControls.addNewProvider')}</span>
</div>
<DropdownMenuSeparator />
{!hasResults && (
<div className="px-2 py-4 text-center typography-meta text-muted-foreground">
No models found
{t('chat.modelControls.noModelsFound')}
</div>
)}
@@ -2278,7 +2315,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
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('chat.modelControls.favorites')}
</DropdownMenuLabel>
{filteredFavorites.map(({ model, providerID, modelID }) => {
const idx = currentFlatIndex++;
@@ -2295,7 +2332,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
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('chat.modelControls.recent')}
</DropdownMenuLabel>
{filteredRecents.map(({ model, providerID, modelID }) => {
const idx = currentFlatIndex++;
@@ -2340,7 +2377,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
forceExpandProviders ? 'cursor-default' : 'cursor-pointer'
)}
aria-expanded={isExpanded}
title={forceExpandProviders ? undefined : (isExpanded ? 'Collapse provider' : 'Expand provider')}
title={forceExpandProviders
? undefined
: (isExpanded
? t('chat.modelControls.collapseProvider')
: t('chat.modelControls.expandProvider'))}
>
<div className="flex min-w-0 items-center gap-2">
<ProviderLogo
@@ -2368,7 +2409,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
{/* Keyboard hints footer */}
<div className="px-3 pt-1 pb-1.5 border-t border-border/40 typography-micro text-muted-foreground">
navigate{highlightedSupportsThinking ? ' • ←→ thinking' : ''} Enter select Esc close
{t('chat.modelControls.keyboardHint', {
thinking: highlightedSupportsThinking ? `${t('chat.modelControls.keyboardHintThinking')}` : '',
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
@@ -2415,7 +2458,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
if (!currentAgent) {
return (
<TooltipContent align="start" sideOffset={8} className="max-w-[320px]">
<div className="min-w-[200px] typography-meta text-muted-foreground">No agent selected.</div>
<div className="min-w-[200px] typography-meta text-muted-foreground">{t('chat.modelControls.noAgentSelected')}</div>
</TooltipContent>
);
}
@@ -2430,12 +2473,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const action = resolveWildcardPermissionAction(rules, permissionName) ?? 'ask';
if (hasCustom) {
return { mode: 'ask', label: 'Custom' };
}
return { mode: 'ask', label: t('chat.modelControls.permissionLabel.custom') };
}
if (action === 'allow') return { mode: 'allow', label: 'Allow' };
if (action === 'deny') return { mode: 'deny', label: 'Deny' };
return { mode: 'ask', label: 'Ask' };
if (action === 'allow') return { mode: 'allow', label: t('chat.modelControls.permissionLabel.allow') };
if (action === 'deny') return { mode: 'deny', label: t('chat.modelControls.permissionLabel.deny') };
return { mode: 'ask', label: t('chat.modelControls.permissionLabel.ask') };
};
const editPermissionSummary = summarizePermission('edit');
@@ -2455,33 +2498,39 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
<div className="flex flex-col gap-1">
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Mode</span>
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">{t('chat.modelControls.mode')}</span>
<span className="typography-meta text-foreground">
{currentAgent.mode === 'primary' ? 'Primary' : currentAgent.mode === 'subagent' ? 'Subagent' : currentAgent.mode === 'all' ? 'All' : '—'}
{currentAgent.mode === 'primary'
? t('chat.modelControls.modeValue.primary')
: currentAgent.mode === 'subagent'
? t('chat.modelControls.modeValue.subagent')
: currentAgent.mode === 'all'
? t('chat.modelControls.modeValue.all')
: t('chat.modelControls.modeValue.none')}
</span>
</div>
{(hasModelConfig || hasTemperatureOrTopP) && (
<div className="flex flex-col gap-1">
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Model</span>
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">{t('chat.modelControls.model')}</span>
{hasModelConfig ? (
<span className="typography-meta text-foreground">
{currentAgent.model!.providerID} / {currentAgent.model!.modelID}
</span>
) : (
<span className="typography-meta text-muted-foreground"></span>
<span className="typography-meta text-muted-foreground">{t('chat.modelControls.modeValue.none')}</span>
)}
{hasTemperatureOrTopP && (
<div className="flex flex-col gap-0.5 mt-0.5">
{currentAgent.temperature !== undefined && (
<div className="flex items-center justify-between gap-3">
<span className="typography-meta text-muted-foreground/80">Temperature</span>
<span className="typography-meta text-muted-foreground/80">{t('chat.modelControls.temperature')}</span>
<span className="typography-meta font-medium text-foreground">{currentAgent.temperature}</span>
</div>
)}
{currentAgent.topP !== undefined && (
<div className="flex items-center justify-between gap-3">
<span className="typography-meta text-muted-foreground/80">Top P</span>
<span className="typography-meta text-muted-foreground/80">{t('chat.modelControls.topP')}</span>
<span className="typography-meta font-medium text-foreground">{currentAgent.topP}</span>
</div>
)}
@@ -2492,9 +2541,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<div className="flex flex-col gap-1">
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Permissions</span>
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">{t('chat.modelControls.permissions')}</span>
<div className="flex items-center gap-3">
<span className="typography-meta text-muted-foreground/80 w-16">Edit</span>
<span className="typography-meta text-muted-foreground/80 w-16">{t('chat.modelControls.edit')}</span>
<div className="flex items-center gap-1.5">
{renderEditModeIcon(editPermissionSummary.mode, 'h-3.5 w-3.5')}
<span className="typography-meta font-medium text-foreground w-12">
@@ -2503,7 +2552,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
</div>
<div className="flex items-center gap-3">
<span className="typography-meta text-muted-foreground/80 w-16">Bash</span>
<span className="typography-meta text-muted-foreground/80 w-16">{t('chat.modelControls.bash')}</span>
<div className="flex items-center gap-1.5">
{renderEditModeIcon(bashPermissionSummary.mode, 'h-3.5 w-3.5')}
<span className="typography-meta font-medium text-foreground w-12">
@@ -2512,7 +2561,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
</div>
<div className="flex items-center gap-3">
<span className="typography-meta text-muted-foreground/80 w-16">WebFetch</span>
<span className="typography-meta text-muted-foreground/80 w-16">{t('chat.modelControls.webFetch')}</span>
<div className="flex items-center gap-1.5">
{renderEditModeIcon(webfetchPermissionSummary.mode, 'h-3.5 w-3.5')}
<span className="typography-meta font-medium text-foreground w-12">
@@ -2524,7 +2573,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
{hasCustomPrompt && (
<div className="flex items-center justify-between gap-3">
<span className="typography-meta text-muted-foreground/80">Custom Prompt</span>
<span className="typography-meta text-muted-foreground/80">{t('chat.modelControls.customPrompt')}</span>
<RiCheckboxCircleLine className="h-4 w-4 text-foreground" />
</div>
)}
@@ -2538,7 +2587,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return null;
}
const displayVariant = currentVariant ?? 'Default';
const displayVariant = currentVariant ?? t('chat.modelControls.default');
const isDefault = !currentVariant;
const colorClass = isDefault ? 'text-muted-foreground' : 'text-[color:var(--status-info)]';
@@ -2594,10 +2643,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end" alignOffset={-40} className="w-[min(180px,calc(100vw-2rem))]">
<DropdownMenuLabel className="typography-ui-header font-semibold text-foreground">Thinking</DropdownMenuLabel>
<DropdownMenuLabel className="typography-ui-header font-semibold text-foreground">{t('chat.modelControls.thinking')}</DropdownMenuLabel>
<DropdownMenuItem className="typography-meta" onSelect={() => handleVariantSelect(undefined)}>
<div className="flex items-center justify-between gap-2 w-full min-w-0">
<span className="typography-meta font-medium text-foreground truncate min-w-0">Default</span>
<span className="typography-meta font-medium text-foreground truncate min-w-0">{t('chat.modelControls.default')}</span>
{isDefault && <RiCheckLine className="h-4 w-4 text-primary flex-shrink-0" />}
</div>
</DropdownMenuItem>
@@ -2667,7 +2716,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<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 agents"
placeholder={t('chat.modelControls.searchAgents')}
value={agentSearchQuery}
onChange={(e) => setAgentSearchQuery(e.target.value)}
onKeyDown={(e) => {
@@ -2688,7 +2737,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
>
<div className="flex items-center gap-1.5">
<RiArrowGoBackLine className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium">Reset to default</span>
<span className="font-medium">{t('chat.modelControls.resetToDefault')}</span>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
@@ -14,8 +14,10 @@ import {
} from './changedFiles';
import { ChangedFilesList } from './ChangedFilesList';
import { changedFilesPopoverClassName, changedFilesPopoverStyle } from './changedFilesPopover';
import { useI18n } from '@/lib/i18n';
export const PendingChangesBar: React.FC = React.memo(() => {
const { t } = useI18n();
const [isExpanded, setIsExpanded] = React.useState(false);
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
const runtime = React.useContext(RuntimeAPIContext);
@@ -102,7 +104,9 @@ export const PendingChangesBar: React.FC = React.memo(() => {
};
const fileCount = gitChangedFiles.length;
const labelHead = `${fileCount} file${fileCount !== 1 ? 's' : ''}`;
const labelHead = fileCount === 1
? t('chat.pendingChanges.fileCountSingle', { count: fileCount })
: t('chat.pendingChanges.fileCountPlural', { count: fileCount });
return (
<div className="relative flex min-w-0 items-center" ref={popoverRef}>
@@ -113,7 +117,9 @@ export const PendingChangesBar: React.FC = React.memo(() => {
>
<RiFileEditLine className="h-3.5 w-3.5 flex-shrink-0 text-[var(--status-warning)]" />
<span className="min-w-0 typography-ui-label text-foreground flex-shrink-0">{labelHead}</span>
<span className="status-row__changed-label min-w-0 typography-ui-label text-foreground truncate">changed in workspace</span>
<span className="status-row__changed-label min-w-0 typography-ui-label text-foreground truncate">
{t('chat.pendingChanges.changedInWorkspace')}
</span>
<span className="text-[0.75rem] tabular-nums inline-flex items-baseline gap-1 flex-shrink-0">
{totalAdded > 0 ? <span style={{ color: 'var(--status-success)' }}>+{totalAdded}</span> : null}
{totalRemoved > 0 ? <span style={{ color: 'var(--status-error)' }}>-{totalRemoved}</span> : null}
@@ -10,6 +10,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { DiffPreview, WritePreview } from './DiffPreview';
import { useI18n } from '@/lib/i18n';
interface PermissionCardProps {
permission: PermissionRequest;
@@ -62,6 +63,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
permission,
onResponse
}) => {
const { t } = useI18n();
const [isResponding, setIsResponding] = React.useState(false);
const [hasResponded, setHasResponded] = React.useState(false);
const respondToPermission = sessionActions.respondToPermission;;
@@ -123,12 +125,12 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
)}
{workingDir && (
<div className="typography-meta text-muted-foreground mb-2">
<span className="font-semibold">Working Directory:</span> <code className="px-1 py-0.5 bg-muted/30 rounded">{workingDir}</code>
<span className="font-semibold">{t('chat.permissionCard.workingDirectory')}</span> <code className="px-1 py-0.5 bg-muted/30 rounded">{workingDir}</code>
</div>
)}
{timeout && (
<div className="typography-meta text-muted-foreground mb-2">
<span className="font-semibold">Timeout:</span> {timeout}ms
<span className="font-semibold">{t('chat.permissionCard.timeout')}</span> {timeout}ms
</div>
)}
{}
@@ -215,7 +217,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
<>
{url && (
<div className="mb-2">
<div className="typography-meta text-muted-foreground mb-1">Request:</div>
<div className="typography-meta text-muted-foreground mb-1">{t('chat.permissionCard.request')}</div>
<div className="flex items-center gap-2">
<span className="typography-meta font-semibold px-1.5 py-0.5 bg-primary/20 text-primary rounded">
{method}
@@ -228,7 +230,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
)}
{headers && Object.keys(headers).length > 0 && (
<div className="mb-2">
<div className="typography-meta text-muted-foreground mb-1">Headers:</div>
<div className="typography-meta text-muted-foreground mb-1">{t('chat.permissionCard.headers')}</div>
<ScrollableOverlay outerClassName="max-h-24" className="p-0">
<SyntaxHighlighter
language="json"
@@ -250,7 +252,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
)}
{body && (
<div className="mb-2">
<div className="typography-meta text-muted-foreground mb-1">Body:</div>
<div className="typography-meta text-muted-foreground mb-1">{t('chat.permissionCard.body')}</div>
<ScrollableOverlay outerClassName="max-h-32" className="p-0">
<SyntaxHighlighter
language={typeof body === 'object' ? 'json' : 'text'}
@@ -291,7 +293,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
)}
{genericContent && (
<div className="mb-2">
<div className="typography-meta text-muted-foreground mb-1">Action:</div>
<div className="typography-meta text-muted-foreground mb-1">{t('chat.permissionCard.action')}</div>
<ScrollableOverlay outerClassName="max-h-32" className="p-0">
<pre className="typography-meta font-mono px-2 py-1 bg-muted/30 rounded whitespace-pre-wrap break-all">
{String(genericContent)}
@@ -302,7 +304,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
{}
{Object.keys(permission.metadata).length > 0 && !genericContent && !description && (
<div>
<div className="typography-meta text-muted-foreground mb-1">Details:</div>
<div className="typography-meta text-muted-foreground mb-1">{t('chat.permissionCard.details')}</div>
<ScrollableOverlay outerClassName="max-h-32" className="p-0">
<pre className="typography-meta font-mono px-2 py-1 bg-muted/30 rounded whitespace-pre-wrap break-all">
{JSON.stringify(permission.metadata, null, 2)}
@@ -343,7 +345,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
<div className="px-2 py-2">
{permission.patterns.length > 0 && (
<div className="mb-2">
<div className="typography-meta text-muted-foreground mb-1">Patterns:</div>
<div className="typography-meta text-muted-foreground mb-1">{t('chat.permissionCard.patterns')}</div>
<code className="typography-meta px-2 py-1 bg-muted/30 rounded block break-all">
{permission.patterns.join(", ")}
</code>
@@ -3,6 +3,7 @@ import { RiCheckLine, RiCloseLine, RiTimeLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import type { PermissionRequest as PermissionRequestPayload, PermissionResponse } from '@/types/permission';
import * as sessionActions from '@/sync/session-actions';
import { useI18n } from '@/lib/i18n';
interface PermissionRequestProps {
permission: PermissionRequestPayload;
@@ -13,6 +14,7 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
permission,
onResponse
}) => {
const { t } = useI18n();
const [isResponding, setIsResponding] = React.useState(false);
const [hasResponded, setHasResponded] = React.useState(false);
const respondToPermission = sessionActions.respondToPermission;;
@@ -42,7 +44,7 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
<div className="flex items-center gap-2 min-w-0 flex-1">
<div className="min-w-0">
<span className="typography-ui-label font-medium text-muted-foreground">
Permission required:
{t('chat.permissionRequest.required')}
</span>
<code className="ml-2 typography-meta bg-amber-100/50 dark:bg-amber-800/30 px-1.5 py-0.5 rounded font-mono text-amber-800 dark:text-amber-200">
{command}
@@ -70,7 +72,7 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
}}
>
<RiCheckLine className="h-3 w-3" />
Once
{t('chat.permissionRequest.actions.once')}
</button>
<button
@@ -92,7 +94,7 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
}}
>
<RiTimeLine className="h-3 w-3" />
Always
{t('chat.permissionRequest.actions.always')}
</button>
<button
@@ -114,7 +116,7 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
}}
>
<RiCloseLine className="h-3 w-3" />
Reject
{t('chat.permissionRequest.actions.reject')}
</button>
{isResponding && (
@@ -125,4 +127,4 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
</div>
</div>
);
};
};
@@ -1,5 +1,6 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
interface PermissionToastActionsProps {
sessionTitle: string;
@@ -27,10 +28,11 @@ export const PermissionToastActions: React.FC<PermissionToastActionsProps> = ({
onAlways,
onDeny,
}) => {
const { t } = useI18n();
const [isBusy, setIsBusy] = React.useState(false);
const actionContext = sessionTitle.trim().length > 0 ? ` for ${sessionTitle}` : '';
const sessionPreview = truncateToastText(sessionTitle, 64) || 'Session';
const permissionPreview = truncateToastText(permissionBody, 120) || 'Permission details unavailable';
const hasSessionTitle = sessionTitle.trim().length > 0;
const sessionPreview = truncateToastText(sessionTitle, 64) || t('chat.permissionToast.sessionFallback');
const permissionPreview = truncateToastText(permissionBody, 120) || t('chat.permissionToast.permissionFallback');
const handleAction = async (action: () => Promise<void> | void) => {
if (isBusy || disabled) return;
@@ -46,13 +48,13 @@ export const PermissionToastActions: React.FC<PermissionToastActionsProps> = ({
<div className="min-w-0">
<div className="mb-1.5 min-w-0 space-y-0.5">
<p className="typography-meta text-muted-foreground" title={sessionTitle}>
Session:{' '}
{t('chat.permissionToast.labels.session')}{' '}
<span className="inline-block max-w-[280px] align-bottom truncate text-foreground">
{sessionPreview}
</span>
</p>
<p className="typography-meta text-muted-foreground" title={permissionBody}>
Permission:{' '}
{t('chat.permissionToast.labels.permission')}{' '}
<span className="inline-block max-w-[280px] align-bottom truncate">
{permissionPreview}
</span>
@@ -63,7 +65,9 @@ export const PermissionToastActions: React.FC<PermissionToastActionsProps> = ({
<button
onClick={() => handleAction(onOnce)}
disabled={disabled || isBusy}
aria-label={`Approve once${actionContext}`}
aria-label={hasSessionTitle
? t('chat.permissionToast.actions.approveOnceAriaWithSession', { session: sessionTitle })
: t('chat.permissionToast.actions.approveOnceAria')}
className={cn(
"px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
"disabled:opacity-50 disabled:cursor-not-allowed"
@@ -79,13 +83,15 @@ export const PermissionToastActions: React.FC<PermissionToastActionsProps> = ({
e.currentTarget.style.backgroundColor = 'rgb(var(--status-success) / 0.1)';
}}
>
Once
{t('chat.permissionToast.actions.once')}
</button>
<button
onClick={() => handleAction(onAlways)}
disabled={disabled || isBusy}
aria-label={`Approve always${actionContext}`}
aria-label={hasSessionTitle
? t('chat.permissionToast.actions.approveAlwaysAriaWithSession', { session: sessionTitle })
: t('chat.permissionToast.actions.approveAlwaysAria')}
className={cn(
"px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
"disabled:opacity-50 disabled:cursor-not-allowed"
@@ -101,13 +107,15 @@ export const PermissionToastActions: React.FC<PermissionToastActionsProps> = ({
e.currentTarget.style.backgroundColor = 'rgb(var(--muted) / 0.5)';
}}
>
Always
{t('chat.permissionToast.actions.always')}
</button>
<button
onClick={() => handleAction(onDeny)}
disabled={disabled || isBusy}
aria-label={`Deny permission${actionContext}`}
aria-label={hasSessionTitle
? t('chat.permissionToast.actions.denyAriaWithSession', { session: sessionTitle })
: t('chat.permissionToast.actions.denyAria')}
className={cn(
"px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
"disabled:opacity-50 disabled:cursor-not-allowed"
@@ -123,7 +131,7 @@ export const PermissionToastActions: React.FC<PermissionToastActionsProps> = ({
e.currentTarget.style.backgroundColor = 'rgb(var(--status-error) / 0.1)';
}}
>
Deny
{t('chat.permissionToast.actions.deny')}
</button>
</div>
</div>
@@ -8,6 +8,7 @@ import type { QuestionRequest } from '@/types/question';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessions } from '@/sync/sync-context';
import * as sessionActions from '@/sync/session-actions';
import { useI18n } from '@/lib/i18n';
interface QuestionCardProps {
question: QuestionRequest;
@@ -17,6 +18,7 @@ type TabKey = string;
const SUMMARY_TAB = 'summary';
export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
const { t } = useI18n();
const respondToQuestion = sessionActions.respondToQuestion;
const rejectQuestion = sessionActions.rejectQuestion;;
const sessions = useSessions();
@@ -59,21 +61,21 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
}));
// Add summary tab when multiple questions
if (questions.length > 1) {
questionTabs.push({ value: SUMMARY_TAB, label: 'Summary' });
questionTabs.push({ value: SUMMARY_TAB, label: t('chat.questionCard.summaryTab') });
}
return questionTabs;
}, [questions]);
}, [questions, t]);
// Helper to get answer display for a question index
const getAnswerDisplay = React.useCallback((index: number): string => {
const isCustom = Boolean(customMode[index]);
if (isCustom) {
const value = (customText[index] ?? '').trim();
return value || '(no answer)';
return value || t('chat.questionCard.noAnswer');
}
const answers = selectedOptions[index] ?? [];
return answers.length > 0 ? answers.join(', ') : '(no answer)';
}, [customMode, customText, selectedOptions]);
return answers.length > 0 ? answers.join(', ') : t('chat.questionCard.noAnswer');
}, [customMode, customText, selectedOptions, t]);
const isMultiple = Boolean(activeQuestion?.multiple);
const selectedForActive = selectedOptions[activeIndex] ?? [];
@@ -197,10 +199,10 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
<div className="px-2 py-1.5 border-b border-border/20">
<div className="flex items-center gap-2">
<RiQuestionLine className="h-3.5 w-3.5 text-primary" />
<span className="typography-meta font-medium text-muted-foreground">Input needed</span>
<span className="typography-meta font-medium text-muted-foreground">{t('chat.questionCard.inputNeeded')}</span>
{isFromSubagent ? (
<span className="typography-micro text-muted-foreground px-1.5 py-0.5 rounded bg-foreground/5">
From subagent
{t('chat.questionCard.fromSubagent')}
</span>
) : null}
{activeHeader ? (
@@ -249,7 +251,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
<div className="space-y-2">
{questions.map((q, index) => {
const answer = getAnswerDisplay(index);
const hasAnswer = answer !== '(no answer)';
const hasAnswer = answer !== t('chat.questionCard.noAnswer');
return (
<button
key={index}
@@ -257,7 +259,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
onClick={() => setActiveTab(String(index))}
className="w-full text-left rounded px-1.5 py-1 hover:bg-interactive-hover/20 transition-colors"
>
<div className="typography-micro text-muted-foreground">{q.header || `Question ${index + 1}`}</div>
<div className="typography-micro text-muted-foreground">{q.header || t('chat.questionCard.questionFallback', { index: index + 1 })}</div>
<div className={cn(
'typography-meta',
hasAnswer ? 'text-foreground' : 'text-muted-foreground/50 italic'
@@ -273,7 +275,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
<div className="typography-meta font-medium text-foreground mb-1.5">{activeQuestion.question}</div>
{isMultiple ? (
<div className="typography-micro text-muted-foreground mb-1.5">Select multiple</div>
<div className="typography-micro text-muted-foreground mb-1.5">{t('chat.questionCard.selectMultiple')}</div>
) : null}
<div className="space-y-0.5">
@@ -320,7 +322,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
{option.label}
</span>
{recommended ? (
<span className="typography-micro text-primary/80">recommended</span>
<span className="typography-micro text-primary/80">{t('chat.questionCard.recommended')}</span>
) : null}
</div>
{option.description ? (
@@ -353,7 +355,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
'typography-meta',
isCustomActive ? 'text-foreground font-medium' : 'text-muted-foreground'
)}>
Other
{t('chat.questionCard.other')}
</span>
</div>
</button>
@@ -380,7 +382,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
el.style.height = `${Math.min(Math.max(el.scrollHeight, minHeight), maxHeight)}px`;
setCustomText((prev) => ({ ...prev, [activeIndex]: el.value }));
}}
placeholder="Your answer"
placeholder={t('chat.questionCard.yourAnswer')}
disabled={isResponding}
rows={2}
className="w-full bg-transparent border border-border/30 focus:border-primary rounded px-2 py-1 outline-none typography-meta text-foreground placeholder:text-muted-foreground/50 transition-colors resize-none overflow-hidden"
@@ -406,7 +408,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
)}
>
{requiredSatisfied ? <RiCheckLine className="h-3 w-3" /> : <RiArrowRightSLine className="h-3 w-3" />}
{requiredSatisfied ? 'Submit' : 'Next'}
{requiredSatisfied ? t('chat.questionCard.submit') : t('chat.questionCard.next')}
</button>
<button
@@ -420,7 +422,7 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
)}
>
<RiCloseLine className="h-3 w-3" />
Dismiss
{t('chat.questionCard.dismiss')}
</button>
{isResponding ? (
@@ -3,6 +3,7 @@ import { RiCloseLine, RiMessage2Line } from '@remixicon/react';
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useInputStore } from '@/sync/input-store';
import { useI18n } from '@/lib/i18n';
interface QueuedMessageChipProps {
message: QueuedMessage;
@@ -11,6 +12,7 @@ interface QueuedMessageChipProps {
}
const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChipProps) => {
const { t } = useI18n();
const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue);
// Get first line of message, truncated
@@ -38,11 +40,11 @@ const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChi
<span className="text-muted-foreground flex-shrink-0">
Queued
{attachmentCount > 0 && (
<span className="ml-1">+{attachmentCount} file{attachmentCount > 1 ? 's' : ''}</span>
<span className="ml-1">{t('chat.queuedMessage.attachments', { count: attachmentCount })}</span>
)}
</span>
<span className="text-foreground truncate">
{firstLine || '(empty)'}
{firstLine || t('chat.queuedMessage.empty')}
</span>
<span
onClick={(e) => {
@@ -50,7 +52,7 @@ const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChi
removeFromQueue(sessionId, message.id);
}}
className="flex items-center justify-center h-6 w-6 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
aria-label="Remove from queue"
aria-label={t('chat.queuedMessage.removeAria')}
>
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
</span>
+21 -16
View File
@@ -22,6 +22,7 @@ import { useTodosPersistStore } from "@/stores/useTodosPersistStore";
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
import { isVSCodeRuntime } from "@/lib/desktop";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useI18n } from "@/lib/i18n";
const statusConfig: Record<TodoStatus, { textClassName: string }> = {
in_progress: {
@@ -50,17 +51,17 @@ const priorityIcon: Record<TodoPriority, React.ReactNode> = {
low: <RiArrowDownSLine className="h-3.5 w-3.5" aria-hidden="true" />,
};
const statusLabel: Record<TodoStatus, string> = {
in_progress: "In progress",
pending: "Pending",
completed: "Completed",
cancelled: "Cancelled",
const statusLabelKey: Record<TodoStatus, string> = {
in_progress: "chat.statusRow.todo.status.inProgress",
pending: "chat.statusRow.todo.status.pending",
completed: "chat.statusRow.todo.status.completed",
cancelled: "chat.statusRow.todo.status.cancelled",
};
const priorityLabel: Record<TodoPriority, string> = {
high: "High priority",
medium: "Medium priority",
low: "Low priority",
const priorityLabelKey: Record<TodoPriority, string> = {
high: "chat.statusRow.todo.priority.high",
medium: "chat.statusRow.todo.priority.medium",
low: "chat.statusRow.todo.priority.low",
};
interface TodoItemRowProps {
@@ -68,7 +69,10 @@ interface TodoItemRowProps {
}
const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
const { t } = useI18n();
const config = statusConfig[todo.status] || statusConfig.pending;
const statusKey = statusLabelKey[todo.status] ?? statusLabelKey.pending;
const priorityKey = priorityLabelKey[todo.priority] ?? priorityLabelKey.medium;
const statusIcon =
todo.status === "in_progress" ? (
@@ -86,7 +90,7 @@ const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
<span className="flex-shrink-0">{statusIcon}</span>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={6}>
{statusLabel[todo.status] ?? statusLabel.pending}
{t(statusKey as never)}
</TooltipContent>
</Tooltip>
<span
@@ -109,7 +113,7 @@ const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{priorityLabel[todo.priority] ?? priorityLabel.medium}
{t(priorityKey as never)}
</TooltipContent>
</Tooltip>
</div>
@@ -154,6 +158,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
agentName,
leftAccessory,
}) => {
const { t } = useI18n();
const [isExpanded, setIsExpanded] = React.useState(false);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const todosRecord = useDirectorySync((state) => state.todo);
@@ -235,7 +240,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
type="button"
onClick={onAbort}
className="flex items-center justify-center h-[1.2rem] w-[1.2rem] text-[var(--status-error)] transition-opacity hover:opacity-80 focus-visible:outline-none flex-shrink-0"
aria-label="Stop generating"
aria-label={t('chat.statusRow.actions.stopGeneratingAria')}
>
<RiCloseCircleLine size={18} aria-hidden="true" />
</button>
@@ -254,10 +259,10 @@ export const StatusRow: React.FC<StatusRowProps> = ({
{activeTodo.content}
</span>
) : (
<span className="typography-ui-label">Tasks</span>
<span className="typography-ui-label">{t('chat.statusRow.tasksTitle')}</span>
)}
<span className="typography-meta">
{statusSummary.active} active · {statusSummary.left} left
{t('chat.statusRow.summary.activeLeft', { active: statusSummary.active, left: statusSummary.left })}
</span>
{isExpanded ? (
<RiArrowUpSLine className="h-3.5 w-3.5" />
@@ -281,7 +286,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5">
<span className="flex items-center gap-1.5 typography-ui-label">
<RiCloseCircleLine size={16} aria-hidden="true" />
Aborted
{t('chat.statusRow.aborted')}
</span>
</div>
) : showAssistantStatus && shouldRenderPlaceholder ? (
@@ -323,7 +328,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
>
{/* Header */}
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
<span>Tasks</span>
<span>{t('chat.statusRow.tasksTitle')}</span>
<span className="typography-meta tabular-nums">
{progress.completed}/{progress.total}
</span>
@@ -12,6 +12,7 @@ import { useSessionMessageRecords } from '@/sync/sync-context';
import { RiLoader4Line, RiSearchLine, RiTimeLine, RiGitBranchLine, RiArrowGoBackLine } from '@remixicon/react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import type { Part } from '@opencode-ai/sdk/v2';
import { useI18n } from '@/lib/i18n';
interface TimelineDialogProps {
open: boolean;
@@ -21,22 +22,6 @@ interface TimelineDialogProps {
onResumeToLatest?: () => void;
}
// Helper: format relative time (e.g., "2 hours ago")
function formatRelativeTime(timestamp: number): string {
const now = Date.now();
const diffMs = now - timestamp;
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return new Date(timestamp).toLocaleDateString();
}
export const TimelineDialog: React.FC<TimelineDialogProps> = ({
open,
onOpenChange,
@@ -44,6 +29,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
onScrollByTurnOffset,
onResumeToLatest,
}) => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const messages = useSessionMessageRecords(currentSessionId ?? '');
const revertToMessage = useSessionUIStore((state) => state.revertToMessage);
@@ -52,6 +38,21 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
const [forkingMessageId, setForkingMessageId] = React.useState<string | null>(null);
const [searchQuery, setSearchQuery] = React.useState('');
const formatRelativeTime = React.useCallback((timestamp: number): string => {
const now = Date.now();
const diffMs = now - timestamp;
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return t('chat.timeline.relative.justNow');
if (diffMins < 60) return t('chat.timeline.relative.minutesAgo', { count: diffMins });
if (diffHours < 24) return t('chat.timeline.relative.hoursAgo', { count: diffHours });
if (diffDays < 7) return t('chat.timeline.relative.daysAgo', { count: diffDays });
return new Date(timestamp).toLocaleDateString();
}, [t]);
// Filter user messages (reversed for newest first)
const userMessages = React.useMemo(() => {
const filtered = messages.filter(m => m.info.role === 'user');
@@ -89,17 +90,17 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RiTimeLine className="h-5 w-5" />
Conversation Timeline
{t('chat.timeline.title')}
</DialogTitle>
<DialogDescription>
Navigate to any point in the conversation or fork a new session
{t('chat.timeline.description')}
</DialogDescription>
</DialogHeader>
<div className="relative mt-2">
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search messages..."
placeholder={t('chat.timeline.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 w-full"
@@ -109,7 +110,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
<div className="flex-1 overflow-y-auto">
{filteredMessages.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
{searchQuery ? 'No messages found' : 'No messages in this session yet'}
{searchQuery ? t('chat.timeline.empty.search') : t('chat.timeline.empty.session')}
</div>
) : (
filteredMessages.map((message) => {
@@ -134,7 +135,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
{messageNumber}.
</span>
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
{preview || '[No text content]'}
{preview || t('chat.timeline.noTextContent')}
{preview && preview.length >= 80 && '…'}
</p>
@@ -158,7 +159,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
<RiArrowGoBackLine className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Revert from here</TooltipContent>
<TooltipContent sideOffset={6}>{t('chat.timeline.actions.revertFromHere')}</TooltipContent>
</Tooltip>
<Tooltip delayDuration={1000}>
@@ -179,7 +180,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
)}
</button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Fork from here</TooltipContent>
<TooltipContent sideOffset={6}>{t('chat.timeline.actions.forkFromHere')}</TooltipContent>
</Tooltip>
</div>
</div>
@@ -190,7 +191,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
</div>
<div className="mt-4 p-3 bg-muted/30 rounded-lg">
<p className="typography-meta text-muted-foreground font-medium mb-2">Actions</p>
<p className="typography-meta text-muted-foreground font-medium mb-2">{t('chat.timeline.actions.title')}</p>
<div className="mb-2 flex items-center gap-2">
<button
type="button"
@@ -200,7 +201,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
onOpenChange(false);
}}
>
Previous turn
{t('chat.timeline.actions.previousTurn')}
</button>
<span className="text-muted-foreground/50">/</span>
<button
@@ -211,20 +212,20 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
onOpenChange(false);
}}
>
Latest
{t('chat.timeline.actions.latest')}
</button>
</div>
<div className="flex flex-col gap-1.5 typography-meta text-muted-foreground">
<div className="flex items-center gap-2">
<span>Click on a message to scroll to it in the conversation</span>
<span>{t('chat.timeline.help.clickMessage')}</span>
</div>
<div className="flex items-center gap-2">
<RiArrowGoBackLine className="h-4 w-4 flex-shrink-0" />
<span>Undo to this point (message text will populate input)</span>
<span>{t('chat.timeline.help.undoToPoint')}</span>
</div>
<div className="flex items-center gap-2">
<RiGitBranchLine className="h-4 w-4 flex-shrink-0" />
<span>Create a new session starting from here</span>
<span>{t('chat.timeline.help.createSessionFromHere')}</span>
</div>
</div>
</div>
@@ -25,6 +25,8 @@ interface TurnChangedFilesDropdownProps {
export const TurnChangedFilesDropdown: React.FC<TurnChangedFilesDropdownProps> = React.memo(({ activityParts }) => {
const [isExpanded, setIsExpanded] = React.useState(false);
const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null);
const triggerButtonRef = React.useRef<HTMLButtonElement | null>(null);
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
const runtime = React.useContext(RuntimeAPIContext);
const isGitRepo = useIsGitRepo(currentDirectory);
@@ -46,6 +48,11 @@ export const TurnChangedFilesDropdown: React.FC<TurnChangedFilesDropdownProps> =
if (changedFiles.length === 0) return null;
const syncPortalContainer = () => {
const container = triggerButtonRef.current?.closest('[data-slot="dialog-content"], [role="dialog"]') as HTMLElement | null;
setPortalContainer(container || null);
};
const handleOpenFile = (file: ChangedFileEntry) => {
if (!currentDirectory) return;
if (isGitFile(file)) return;
@@ -82,9 +89,12 @@ export const TurnChangedFilesDropdown: React.FC<TurnChangedFilesDropdownProps> =
<Popover.Trigger
render={
<button
ref={triggerButtonRef}
type="button"
className="flex items-center gap-1 text-sm text-muted-foreground/60 hover:text-muted-foreground tabular-nums"
aria-label={`${label} changed in this turn`}
onPointerDownCapture={syncPortalContainer}
onFocusCapture={syncPortalContainer}
>
<RiFileEditLine className="h-3.5 w-3.5" />
<span className="message-footer__label">{label}</span>
@@ -99,7 +109,7 @@ export const TurnChangedFilesDropdown: React.FC<TurnChangedFilesDropdownProps> =
</TooltipTrigger>
<TooltipContent>{label} changed in this turn</TooltipContent>
</Tooltip>
<Popover.Portal>
<Popover.Portal container={portalContainer || undefined}>
<Popover.Positioner side="top" align="start" sideOffset={4} collisionPadding={8}>
<Popover.Popup
style={changedFilesPopoverStyle}
@@ -13,6 +13,7 @@ import {
getQuickEffortOptions,
parseEffortVariant,
} from './mobileControlsUtils';
import { useI18n } from '@/lib/i18n';
const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', {
notation: 'compact',
@@ -43,6 +44,7 @@ export const UnifiedControlsDrawer: React.FC<UnifiedControlsDrawerProps> = ({
onOpenModel,
onOpenEffort,
}) => {
const { t } = useI18n();
const providers = useConfigStore((state) => state.providers);
const currentProviderId = useConfigStore((state) => state.currentProviderId);
const currentModelId = useConfigStore((state) => state.currentModelId);
@@ -156,16 +158,16 @@ export const UnifiedControlsDrawer: React.FC<UnifiedControlsDrawerProps> = ({
};
return (
<MobileOverlayPanel open={open} onClose={onClose} title="Controls">
<MobileOverlayPanel open={open} onClose={onClose} title={t('chat.unifiedControls.title')}>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<div className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground">
Model
{t('chat.unifiedControls.model.title')}
</div>
<div className="rounded-xl border border-border/40 overflow-hidden">
{recentModels.length === 0 && !hasCurrentInRecents && (
<div className="px-3 py-2 typography-meta text-muted-foreground">
No recent models
{t('chat.unifiedControls.model.noRecent')}
</div>
)}
{recentModels.map(({ providerID, modelID, model }) => {
@@ -204,7 +206,7 @@ export const UnifiedControlsDrawer: React.FC<UnifiedControlsDrawerProps> = ({
type="button"
onClick={onOpenModel}
className="flex min-h-[44px] w-full items-center justify-center border-t border-border/30 px-3 py-2 typography-meta font-medium text-muted-foreground"
aria-label="More models"
aria-label={t('chat.unifiedControls.model.moreAria')}
>
...
</button>
@@ -214,7 +216,7 @@ export const UnifiedControlsDrawer: React.FC<UnifiedControlsDrawerProps> = ({
{hasEffort && (
<div className="flex flex-col gap-2">
<div className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground">
Effort
{t('chat.unifiedControls.effort.title')}
</div>
<div className="flex flex-wrap gap-2">
{quickEfforts.map((variant) => {
@@ -241,7 +243,7 @@ export const UnifiedControlsDrawer: React.FC<UnifiedControlsDrawerProps> = ({
type="button"
onClick={onOpenEffort}
className="inline-flex items-center rounded-full border border-border/40 px-2.5 py-1 typography-meta font-medium text-muted-foreground hover:bg-interactive-hover/50"
aria-label="More effort options"
aria-label={t('chat.unifiedControls.effort.moreAria')}
>
...
</button>
@@ -3,6 +3,7 @@ import { RiArrowDownLine } from '@remixicon/react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
interface ScrollToBottomButtonProps {
visible: boolean;
@@ -10,6 +11,7 @@ interface ScrollToBottomButtonProps {
}
const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({ visible, onClick }) => {
const { t } = useI18n();
return (
<div
className={cn(
@@ -22,7 +24,7 @@ const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({ visible, on
size="sm"
onClick={onClick}
className="size-8 rounded-full [corner-shape:round] p-0 shadow-none bg-background/95 hover:bg-interactive-hover"
aria-label="Scroll to bottom"
aria-label={t('chat.scrollToBottom.aria')}
>
<RiArrowDownLine className="h-4 w-4" />
</Button>
@@ -42,6 +42,7 @@ import { createProjectPlanFile } from '@/lib/openchamberConfig';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useSessions } from '@/sync/sync-context';
import { useI18n } from '@/lib/i18n';
type SubtaskPartLike = Part & {
type: 'subtask';
@@ -86,6 +87,7 @@ const normalizeSubtaskModel = (model: SubtaskPartLike['model']): string | null =
const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
const [expanded, setExpanded] = React.useState(false);
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const { t } = useI18n();
const description = typeof part.description === 'string' ? part.description.trim() : '';
const command = typeof part.command === 'string' ? part.command.trim() : '';
@@ -97,7 +99,7 @@ const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
return (
<div className="mt-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="typography-meta font-semibold text-foreground">Delegated task</span>
<span className="typography-meta font-semibold text-foreground">{t('chat.messageBody.subtask.title')}</span>
{command ? (
<span className="inline-flex h-5 items-center rounded px-1.5 text-[11px] leading-none bg-foreground/5 text-muted-foreground">
/{command}
@@ -128,7 +130,7 @@ const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
className="typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
onClick={() => setExpanded((value) => !value)}
>
{expanded ? 'Hide prompt' : 'Show prompt'}
{expanded ? t('chat.messageBody.subtask.hidePrompt') : t('chat.messageBody.subtask.showPrompt')}
</button>
{expanded ? (
<pre className="typography-meta mt-1.5 overflow-x-auto whitespace-pre-wrap break-words text-foreground/85">
@@ -147,7 +149,7 @@ const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
void setCurrentSession(taskSessionID);
}}
>
Open subtask session
{t('chat.messageBody.subtask.openSession')}
</button>
</div>
) : null}
@@ -159,6 +161,7 @@ const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part })
const [expanded, setExpanded] = React.useState(false);
const [copiedOutput, setCopiedOutput] = React.useState(false);
const copiedResetTimeoutRef = React.useRef<number | null>(null);
const { t } = useI18n();
const command = typeof part.shellAction?.command === 'string' ? part.shellAction.command.trim() : '';
const output = typeof part.shellAction?.output === 'string' ? part.shellAction.output : '';
@@ -197,7 +200,7 @@ const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part })
return (
<div className="mt-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="typography-meta font-semibold text-foreground">Shell command</span>
<span className="typography-meta font-semibold text-foreground">{t('chat.messageBody.shellCommand.title')}</span>
{status ? (
<span className={cn(
'inline-flex h-5 items-center rounded px-1.5 text-[11px] leading-none',
@@ -224,7 +227,7 @@ const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part })
className="typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
onClick={() => setExpanded((value) => !value)}
>
{expanded ? 'Hide output' : 'Show output'}
{expanded ? t('chat.messageBody.shellCommand.hideOutput') : t('chat.messageBody.shellCommand.showOutput')}
</button>
<button
type="button"
@@ -232,8 +235,8 @@ const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part })
onClick={() => {
void copyOutputToClipboard();
}}
aria-label={copiedOutput ? 'Copied' : 'Copy output'}
title={copiedOutput ? 'Copied' : 'Copy output'}
aria-label={copiedOutput ? t('chat.messageBody.shellCommand.copied') : t('chat.messageBody.shellCommand.copyOutput')}
title={copiedOutput ? t('chat.messageBody.shellCommand.copied') : t('chat.messageBody.shellCommand.copyOutput')}
>
{copiedOutput ? <RiCheckLine className="h-3.5 w-3.5" /> : <RiFileCopyLine className="h-3.5 w-3.5" />}
</button>
@@ -332,6 +335,7 @@ const UserMessageBody: React.FC<{
userActionsMode?: 'inline' | 'external-content' | 'external-actions';
stickyUserHeaderEnabled?: boolean;
}> = ({ messageId, parts, isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork, userActionsMode = 'inline', stickyUserHeaderEnabled = true }) => {
const { t } = useI18n();
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
const copyHintTimeoutRef = React.useRef<number | null>(null);
@@ -441,7 +445,7 @@ const UserMessageBody: React.FC<{
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label="Revert to this message"
aria-label={t('chat.messageBody.actions.revertAria')}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
@@ -451,7 +455,7 @@ const UserMessageBody: React.FC<{
<RiArrowGoBackLine className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Revert from here</TooltipContent>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.revert')}</TooltipContent>
</Tooltip>
)}
{onFork && (
@@ -462,7 +466,7 @@ const UserMessageBody: React.FC<{
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label="Fork from this message"
aria-label={t('chat.messageBody.actions.forkAria')}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
@@ -472,7 +476,7 @@ const UserMessageBody: React.FC<{
<RiGitBranchLine className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Fork from here</TooltipContent>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.fork')}</TooltipContent>
</Tooltip>
)}
{canCopyMessage && hasCopyableText && (
@@ -484,7 +488,7 @@ const UserMessageBody: React.FC<{
size="icon"
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label="Copy message text"
aria-label={t('chat.messageBody.actions.copyMessageAria')}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleCopyButtonClick}
onFocus={() => setCopyHintVisible(true)}
@@ -501,7 +505,7 @@ const UserMessageBody: React.FC<{
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Copy message</TooltipContent>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.copyMessage')}</TooltipContent>
</Tooltip>
)}
</div>
@@ -596,6 +600,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
turnGroupingContext,
errorMessage,
}) => {
const { t } = useI18n();
const streamPhase = _streamPhase;
void _allowAnimation;
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
@@ -729,11 +734,11 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
const readAloudTooltip = React.useMemo(() => {
if (isTTSPlaying) {
return 'Stop speaking';
return t('chat.messageBody.tts.stopSpeaking');
}
const providerLabel = voiceProvider === 'browser' ? 'Browser' : voiceProvider === 'openai' ? 'OpenAI' : voiceProvider === 'openai-compatible' ? 'Custom' : 'Say';
return `Read aloud (${providerLabel} voice)`;
}, [isTTSPlaying, voiceProvider]);
return t('chat.messageBody.tts.readAloudWithProvider', { provider: providerLabel });
}, [isTTSPlaying, t, voiceProvider]);
const currentSession = React.useMemo(() => {
if (!currentSessionId) {
@@ -979,7 +984,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
return;
}
if (!currentProjectRef) {
toast.error('No project found for this session');
toast.error(t('chat.messageBody.toast.noProject'));
return;
}
@@ -990,14 +995,14 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
body: assistantPlanText,
});
if (!created) {
toast.error('Failed to save plan');
toast.error(t('chat.messageBody.toast.savePlanFailed'));
return;
}
window.dispatchEvent(new CustomEvent('openchamber:project-plan-saved', {
detail: { projectId: currentProjectRef.id },
}));
setIsPlanDialogOpen(false);
toast.success('Plan saved');
toast.success(t('chat.messageBody.toast.planSaved'));
} finally {
setIsSavingPlan(false);
}
@@ -1104,10 +1109,10 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
document.body.removeChild(link);
}
toast.success('Image saved');
toast.success(t('chat.messageBody.toast.imageSaved'));
} catch (error) {
console.error('Failed to generate image:', error);
toast.error('Failed to generate image');
toast.error(t('chat.messageBody.toast.generateImageFailed'));
} finally {
if (wrapper && wrapper.parentNode) {
wrapper.parentNode.removeChild(wrapper);
@@ -1424,7 +1429,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
!hasCopyableText && 'opacity-50'
)}
disabled={!hasCopyableText}
aria-label="Copy message text"
aria-label={t('chat.messageBody.actions.copyMessageAria')}
aria-hidden={!hasCopyableText}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleCopyButtonClick}
@@ -1446,7 +1451,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Copy answer</TooltipContent>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.copyAnswer')}</TooltipContent>
</Tooltip>
)}
<Tooltip delayDuration={1000}>
@@ -1470,7 +1475,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{isSharing ? 'Saving image...' : 'Save as image'}</TooltipContent>
<TooltipContent sideOffset={6}>{isSharing ? t('chat.messageBody.actions.savingImage') : t('chat.messageBody.actions.saveAsImage')}</TooltipContent>
</Tooltip>
{!isVSCodeRuntime() ? (
<Tooltip delayDuration={1000}>
@@ -1490,7 +1495,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
<RiBookletLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Save as plan</TooltipContent>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.saveAsPlan')}</TooltipContent>
</Tooltip>
) : null}
<Tooltip delayDuration={1000}>
@@ -1506,7 +1511,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
<RiChatNewLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Start new session from this answer</TooltipContent>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.startNewSession')}</TooltipContent>
</Tooltip>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
@@ -1521,7 +1526,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
<ArrowsMerge className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Start new multi-run from this answer</TooltipContent>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.startNewMultiRun')}</TooltipContent>
</Tooltip>
{showMessageTTSButtons && hasCopyableText && (
@@ -1535,7 +1540,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
'h-8 w-8 bg-transparent hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
isTTSPlaying ? 'text-green-500' : 'text-muted-foreground hover:text-foreground'
)}
aria-label={isTTSPlaying ? 'Stop speaking' : 'Read aloud'}
aria-label={isTTSPlaying ? t('chat.messageBody.tts.stopSpeaking') : t('chat.messageBody.tts.readAloud')}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleTTSClick}
>
@@ -14,6 +14,7 @@ import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { summarizeText } from '@/lib/voice/summarize';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
interface TextSelectionMenuProps {
containerRef: React.RefObject<HTMLElement | null>;
@@ -206,6 +207,7 @@ const rangeToMarkdown = (range: Range, plainText: string): string => {
};
export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerRef }) => {
const { t } = useI18n();
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0, show: false });
const [selectedText, setSelectedText] = React.useState('');
const [selectedTextMarkdown, setSelectedTextMarkdown] = React.useState('');
@@ -498,7 +500,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const handleAddToNotes = React.useCallback(async () => {
if (!selectedText || !currentProjectRef) {
if (!currentProjectRef) {
toast.error('No project found for this session');
toast.error(t('chat.textSelection.toast.noProject'));
}
return;
}
@@ -517,18 +519,18 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
todos: projectData.todos,
});
if (!saved) {
toast.error('Failed to add to notes');
toast.error(t('chat.textSelection.toast.addToNotesFailed'));
return;
}
window.dispatchEvent(new CustomEvent('openchamber:project-notes-updated', {
detail: { projectId: currentProjectRef.id },
}));
toast.success('Added distilled insight to notes');
toast.success(t('chat.textSelection.toast.addToNotesSuccess'));
hideMenu();
window.getSelection()?.removeAllRanges();
} catch (error) {
const description = error instanceof Error ? error.message : undefined;
toast.error('Failed to add to notes', description ? { description } : undefined);
toast.error(t('chat.textSelection.toast.addToNotesFailed'), description ? { description } : undefined);
} finally {
setIsAddingToNotes(false);
}
@@ -566,7 +568,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
type="button"
>
<RiAddLine className="h-5 w-5" />
<span>Add to chat</span>
<span>{t('chat.textSelection.actions.addToChat')}</span>
</button>
<button
@@ -581,7 +583,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
type="button"
>
<RiChatNewLine className="h-5 w-5" />
<span>New session</span>
<span>{t('chat.textSelection.actions.newSession')}</span>
</button>
<button
@@ -596,7 +598,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
type="button"
>
<RiFileCopyLine className="h-5 w-5" />
<span>Copy</span>
<span>{t('chat.textSelection.actions.copy')}</span>
</button>
{!isVSCodeRuntime() ? (
@@ -613,7 +615,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
type="button"
>
{isAddingToNotes ? <RiLoader4Line className="h-5 w-5 animate-spin" /> : <RiBookletLine className="h-5 w-5" />}
<span>Add to notes</span>
<span>{t('chat.textSelection.actions.addToNotes')}</span>
</button>
) : null}
</div>,
@@ -651,11 +653,11 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
'hover:bg-[var(--interactive-hover)]',
'transition-colors duration-150'
)}
title="Add to current chat"
title={t('chat.textSelection.title.addToCurrentChat')}
type="button"
>
<RiAddLine className="h-4 w-4" />
<span className="whitespace-nowrap">Add to chat</span>
<span className="whitespace-nowrap">{t('chat.textSelection.actions.addToChat')}</span>
</button>
<div className="w-px h-4 bg-[var(--interactive-border)]" />
@@ -669,11 +671,11 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
'hover:bg-[var(--interactive-hover)]',
'transition-colors duration-150'
)}
title="Create new session with selection"
title={t('chat.textSelection.title.newSessionWithSelection')}
type="button"
>
<RiChatNewLine className="h-4 w-4" />
<span className="whitespace-nowrap">New session</span>
<span className="whitespace-nowrap">{t('chat.textSelection.actions.newSession')}</span>
</button>
{!isVSCodeRuntime() ? (
@@ -690,11 +692,11 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
'hover:bg-[var(--interactive-hover)] disabled:opacity-60 disabled:cursor-not-allowed',
'transition-colors duration-150'
)}
title="Save distilled insight to notes"
title={t('chat.textSelection.title.saveInsightToNotes')}
type="button"
>
{isAddingToNotes ? <RiLoader4Line className="h-4 w-4 animate-spin" /> : <RiBookletLine className="h-4 w-4" />}
<span className="whitespace-nowrap">Add to notes</span>
<span className="whitespace-nowrap">{t('chat.textSelection.actions.addToNotes')}</span>
</button>
</>
) : null}
@@ -26,6 +26,7 @@ import type { ToolPopupContent, DiffViewMode } from './types';
import { DiffViewToggle } from './DiffViewToggle';
import { VirtualizedCodeBlock, type CodeLine } from './parts/VirtualizedCodeBlock';
import { JsonTreeView } from '@/components/ui/JsonTreeView';
import { useI18n } from '@/lib/i18n';
interface ToolOutputDialogProps {
popup: ToolPopupContent;
@@ -302,6 +303,7 @@ const ImagePreviewDialog: React.FC<{
onOpenChange: (open: boolean) => void;
isMobile: boolean;
}> = ({ popup, onOpenChange, isMobile }) => {
const { t } = useI18n();
const gallery = React.useMemo(() => {
const baseImage = popup.image;
if (!baseImage) return [] as Array<{ url: string; mimeType?: string; filename?: string; size?: number }>;
@@ -434,7 +436,7 @@ const ImagePreviewDialog: React.FC<{
onMouseDown={(event) => event.stopPropagation()}
onClick={showPrevious}
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 h-10 w-10 flex items-center justify-center rounded-full bg-black/40 text-foreground/90 hover:bg-black/55 focus:outline-none focus:ring-2 focus:ring-primary/60"
aria-label="Previous image"
aria-label={t('chat.toolOutputDialog.image.previousAria')}
>
<RiArrowLeftSLine className="h-6 w-6" />
</button>
@@ -443,7 +445,7 @@ const ImagePreviewDialog: React.FC<{
onMouseDown={(event) => event.stopPropagation()}
onClick={showNext}
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 h-10 w-10 flex items-center justify-center rounded-full bg-black/40 text-foreground/90 hover:bg-black/55 focus:outline-none focus:ring-2 focus:ring-primary/60"
aria-label="Next image"
aria-label={t('chat.toolOutputDialog.image.nextAria')}
>
<RiArrowRightSLine className="h-6 w-6" />
</button>
@@ -472,7 +474,7 @@ const ImagePreviewDialog: React.FC<{
type="button"
className="h-8 w-8 flex items-center justify-center rounded-lg text-muted-foreground/80 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-primary/60"
onClick={() => onOpenChange(false)}
aria-label="Close image preview"
aria-label={t('chat.toolOutputDialog.image.closeAria')}
>
<RiCloseLine className="h-4 w-4" />
</button>
@@ -632,6 +634,7 @@ const MermaidPreviewDialog: React.FC<{
onOpenChange: (open: boolean) => void;
isMobile: boolean;
}> = ({ popup, onOpenChange, isMobile }) => {
const { t } = useI18n();
const [source, setSource] = React.useState<string>(popup.mermaid?.source || '');
const [status, setStatus] = React.useState<'idle' | 'loading' | 'ready' | 'error'>(popup.mermaid?.source ? 'ready' : 'idle');
const [errorMessage, setErrorMessage] = React.useState<string>('');
@@ -707,7 +710,7 @@ const MermaidPreviewDialog: React.FC<{
const target = popup.mermaid;
if (!target?.url) {
setStatus('error');
setErrorMessage('Missing Mermaid source URL.');
setErrorMessage(t('chat.toolOutputDialog.mermaid.missingSource'));
return;
}
@@ -773,7 +776,7 @@ const MermaidPreviewDialog: React.FC<{
return;
}
setStatus('error');
setErrorMessage(error instanceof Error ? error.message : 'Unable to load Mermaid diagram.');
setErrorMessage(error instanceof Error ? error.message : t('chat.toolOutputDialog.mermaid.loadFailed'));
});
}, [decodeDataUrl, normalizeFilePath, popup.mermaid]);
@@ -918,7 +921,7 @@ const MermaidPreviewDialog: React.FC<{
type="button"
className="h-8 w-8 flex items-center justify-center rounded-lg text-muted-foreground/80 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-primary/60"
onClick={() => onOpenChange(false)}
aria-label="Close diagram preview"
aria-label={t('chat.toolOutputDialog.mermaid.closeAria')}
>
<RiCloseLine className="h-4 w-4" />
</button>
@@ -931,14 +934,14 @@ const MermaidPreviewDialog: React.FC<{
{status === 'loading' && (
<div className="h-full min-h-28 flex items-center justify-center gap-2 text-muted-foreground typography-meta">
<RiLoader4Line className="h-4 w-4 animate-spin" />
<span>Loading diagram...</span>
<span>{t('chat.toolOutputDialog.mermaid.loading')}</span>
</div>
)}
{status === 'error' && (
<div className="rounded-xl border border-border/30 bg-muted/20 p-3 space-y-3">
<p className="typography-markdown" style={{ color: 'var(--status-error)' }}>
{errorMessage || 'Unable to render Mermaid diagram.'}
{errorMessage || t('chat.toolOutputDialog.mermaid.renderFailed')}
</p>
<button
type="button"
@@ -951,7 +954,7 @@ const MermaidPreviewDialog: React.FC<{
color: 'var(--surface-foreground)',
}}
>
Retry
{t('chat.toolOutputDialog.mermaid.retry')}
</button>
</div>
)}
@@ -983,6 +986,7 @@ const MermaidPreviewDialog: React.FC<{
};
const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange, syntaxTheme, isMobile }) => {
const { t } = useI18n();
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>('unified');
const pierreThemeConfig = usePierreThemeConfig();
@@ -1112,7 +1116,13 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
if (tool === 'todowrite' || tool === 'todoread') {
return (
renderTodoOutput(popup.content) || (
renderTodoOutput(popup.content, {
total: t('chat.todo.total'),
inProgress: t('chat.todo.inProgress'),
pending: t('chat.todo.pending'),
completed: t('chat.todo.completed'),
cancelled: t('chat.todo.cancelled'),
}) || (
<SyntaxHighlighter
style={syntaxTheme}
language="json"
@@ -1214,8 +1224,8 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
</div>
) : (
<div className="p-8 text-muted-foreground typography-ui-header">
<div className="mb-2">Command completed successfully</div>
<div className="typography-meta">No output was produced</div>
<div className="mb-2">{t('chat.toolOutputDialog.commandCompleted')}</div>
<div className="typography-meta">{t('chat.toolOutputDialog.noOutputProduced')}</div>
</div>
)}
</div>
@@ -41,6 +41,7 @@ import { getToolIcon } from './toolPresentation';
import { useDurationTickerNow } from './useDurationTicker';
import { resolveFallbackTaskSessionId } from './resolveFallbackTaskSessionId';
import { areRenderRelevantPartsEqual } from '../renderCompare';
import { useI18n } from '@/lib/i18n';
type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record<string, unknown>; input?: Record<string, unknown>; output?: string; error?: string; time?: { start: number; end?: number } };
@@ -1060,6 +1061,7 @@ const TaskToolSummary: React.FC<{
animateTailText?: boolean;
isActive?: boolean;
}> = ({ entries, isExpanded, isMobile, output, sessionId, onShowPopup, input, animateTailText = true, isActive = false }) => {
const { t } = useI18n();
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const showToolFileIcons = useUIStore((state) => state.showToolFileIcons);
const displayEntries = entries;
@@ -1171,7 +1173,7 @@ const TaskToolSummary: React.FC<{
onClick={handleOpenSession}
>
<RiExternalLinkLine className="h-3.5 w-3.5 flex-shrink-0" />
<span className="typography-meta text-primary font-medium">Open {agentType.charAt(0).toUpperCase() + agentType.slice(1)} subtask</span>
<span className="typography-meta text-primary font-medium">{t('chat.toolPart.openSubtask', { type: agentType.charAt(0).toUpperCase() + agentType.slice(1) })}</span>
</button>
)}
@@ -1192,7 +1194,7 @@ const TaskToolSummary: React.FC<{
) : (
<RiArrowRightSLine className="h-3.5 w-3.5 flex-shrink-0" />
)}
<span className="typography-meta text-foreground/80 font-medium">Output</span>
<span className="typography-meta text-foreground/80 font-medium">{t('chat.toolPart.output')}</span>
</button>
{isOutputExpanded ? (
<ToolScrollableSection maxHeightClass="max-h-[50vh]">
@@ -1409,6 +1411,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
currentDirectory,
onShowPopup,
}) => {
const { t } = useI18n();
const { pierreTheme, pierreThemeType } = usePierreThemeConfig();
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>('unified');
const stateWithData = state as ToolStateWithMetadata;
@@ -1497,7 +1500,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
}}
>
<div className="typography-meta font-medium" style={{ color: 'var(--status-error)' }}>
LSP errors
{t('chat.toolPart.lspErrors')}
</div>
<div className="space-y-1">
<div className="flex items-center gap-1 min-w-0">
@@ -1519,7 +1522,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
</div>
{diagnosticSection.remaining > 0 ? (
<div className="typography-micro text-muted-foreground">
+{diagnosticSection.remaining} more errors
{t('chat.toolPart.moreErrors', { count: diagnosticSection.remaining })}
</div>
) : null}
</div>
@@ -1549,7 +1552,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
if (state.status === 'error' && 'error' in state) {
return (
<div>
<div className="typography-meta font-medium text-muted-foreground mb-1">Error:</div>
<div className="typography-meta font-medium text-muted-foreground mb-1">{t('chat.toolPart.error')}</div>
<div className="typography-meta p-2 rounded-xl border" style={{
backgroundColor: 'var(--status-error-background)',
color: 'var(--status-error)',
@@ -1590,7 +1593,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
);
}
return <div className="typography-meta text-muted-foreground">Awaiting response...</div>;
return <div className="typography-meta text-muted-foreground">{t('chat.toolPart.awaitingResponse')}</div>;
}
if (part.tool === 'task' && hasStringOutput) {
@@ -1655,7 +1658,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
}
return renderScrollableBlock(
<div className="typography-meta text-muted-foreground/70">No output produced</div>,
<div className="typography-meta text-muted-foreground/70">{t('chat.toolPart.noOutputProduced')}</div>,
{ maxHeightClass: 'max-h-60' }
);
};
@@ -1714,7 +1717,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
{state.status === 'error' && 'error' in state && (
<div>
<div className="typography-meta font-medium text-muted-foreground/80 mb-1">Error:</div>
<div className="typography-meta font-medium text-muted-foreground/80 mb-1">{t('chat.toolPart.error')}</div>
<div className="typography-meta p-2 rounded-xl border" style={{
backgroundColor: 'var(--status-error-background)',
color: 'var(--status-error)',
@@ -371,7 +371,17 @@ type Todo = {
priority?: 'high' | 'medium' | 'low';
};
export const renderTodoOutput = (output: string, options?: { unstyled?: boolean }) => {
export const renderTodoOutput = (
output: string,
labels: {
total: string;
inProgress: string;
pending: string;
completed: string;
cancelled: string;
},
options?: { unstyled?: boolean },
) => {
try {
const todos = JSON.parse(output) as Todo[];
if (!Array.isArray(todos)) {
@@ -408,18 +418,18 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean
style={typography.tool.popup}
>
<div className="flex gap-4 typography-meta pb-2 border-b border-border/20">
<span className="font-medium" style={{ color: 'var(--muted-foreground)' }}>Total: {todos.length}</span>
<span className="font-medium" style={{ color: 'var(--muted-foreground)' }}>{labels.total}: {todos.length}</span>
{todosByStatus.in_progress.length > 0 && (
<span className="font-medium" style={{ color: 'var(--foreground)' }}>In Progress: {todosByStatus.in_progress.length}</span>
<span className="font-medium" style={{ color: 'var(--foreground)' }}>{labels.inProgress}: {todosByStatus.in_progress.length}</span>
)}
{todosByStatus.pending.length > 0 && (
<span style={{ color: 'var(--muted-foreground)' }}>Pending: {todosByStatus.pending.length}</span>
<span style={{ color: 'var(--muted-foreground)' }}>{labels.pending}: {todosByStatus.pending.length}</span>
)}
{todosByStatus.completed.length > 0 && (
<span style={{ color: 'var(--status-success)' }}>Completed: {todosByStatus.completed.length}</span>
<span style={{ color: 'var(--status-success)' }}>{labels.completed}: {todosByStatus.completed.length}</span>
)}
{todosByStatus.cancelled.length > 0 && (
<span style={{ color: 'var(--muted-foreground)', opacity: 0.5 }}>Cancelled: {todosByStatus.cancelled.length}</span>
<span style={{ color: 'var(--muted-foreground)', opacity: 0.5 }}>{labels.cancelled}: {todosByStatus.cancelled.length}</span>
)}
</div>
@@ -427,7 +437,7 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full animate-pulse" style={{ backgroundColor: 'var(--foreground)' }} />
<span className="typography-meta font-semibold text-foreground uppercase tracking-wide">In Progress</span>
<span className="typography-meta font-semibold text-foreground uppercase tracking-wide">{labels.inProgress}</span>
</div>
<div className="space-y-1.5 pl-4">
{todosByStatus.in_progress.map((todo, idx) => (
@@ -444,7 +454,7 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-muted-foreground/50" />
<span className="typography-meta font-semibold text-muted-foreground uppercase tracking-wide">Pending</span>
<span className="typography-meta font-semibold text-muted-foreground uppercase tracking-wide">{labels.pending}</span>
</div>
<div className="space-y-1.5 pl-4">
{todosByStatus.pending.map((todo, idx) => (
@@ -461,7 +471,7 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean
<div className="space-y-2">
<div className="flex items-center gap-2">
<RiCheckLine className="w-3 h-3" style={{ color: 'var(--status-success)' }} />
<span className="typography-meta font-semibold uppercase tracking-wide" style={{ color: 'var(--status-success)' }}>Completed</span>
<span className="typography-meta font-semibold uppercase tracking-wide" style={{ color: 'var(--status-success)' }}>{labels.completed}</span>
</div>
<div className="space-y-1.5 pl-4">
{todosByStatus.completed.map((todo, idx) => (
@@ -478,7 +488,7 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="w-3 h-3 text-muted-foreground/50">×</span>
<span className="typography-meta font-semibold text-muted-foreground/50 uppercase tracking-wide">Cancelled</span>
<span className="typography-meta font-semibold text-muted-foreground/50 uppercase tracking-wide">{labels.cancelled}</span>
</div>
<div className="space-y-1.5 pl-4">
{todosByStatus.cancelled.map((todo, idx) => (
@@ -11,6 +11,7 @@ import {
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useI18n } from '@/lib/i18n';
interface InlineCommentCardProps {
draft: InlineCommentDraft;
@@ -27,6 +28,7 @@ export function InlineCommentCard({
className,
maxWidth,
}: InlineCommentCardProps) {
const { t } = useI18n();
const themeContext = useOptionalThemeSystem();
const currentTheme = themeContext?.currentTheme;
const [isOpen, setIsOpen] = useState(false);
@@ -56,7 +58,7 @@ export function InlineCommentCard({
{draft.fileLabel}
</span>
<span></span>
<span>Lines {draft.startLine}-{draft.endLine}</span>
<span>{t('inlineComment.range.lines', { start: draft.startLine, end: draft.endLine })}</span>
{draft.side && <span>({draft.side})</span>}
</div>
@@ -75,12 +77,12 @@ export function InlineCommentCard({
{isOpen ? (
<>
<RiArrowUpSLine className="size-3 mr-1" />
Show less
{t('inlineComment.actions.showLess')}
</>
) : (
<>
<RiArrowDownSLine className="size-3 mr-1" />
Show more
{t('inlineComment.actions.showMore')}
</>
)}
</Button>
@@ -106,11 +108,11 @@ export function InlineCommentCard({
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit}>
<RiEditLine className="size-4 mr-2" />
Edit comment
{t('inlineComment.actions.editComment')}
</DropdownMenuItem>
<DropdownMenuItem onClick={onDelete} className="text-destructive">
<RiDeleteBinLine className="size-4 mr-2" />
Delete comment
{t('inlineComment.actions.deleteComment')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { useDeviceInfo } from '@/lib/device';
import { useI18n } from '@/lib/i18n';
export interface InlineCommentInputProps {
initialText?: string;
@@ -26,6 +27,7 @@ export function InlineCommentInput({
className,
maxWidth,
}: InlineCommentInputProps) {
const { t } = useI18n();
const themeContext = useOptionalThemeSystem();
const currentTheme = themeContext?.currentTheme;
const { isMobile } = useDeviceInfo();
@@ -131,7 +133,11 @@ export function InlineCommentInput({
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground mb-2">
{fileLabel && <span className="truncate max-w-[200px]">{fileLabel}</span>}
{fileLabel && lineRange && <span></span>}
{displayRange && <span>Lines {displayRange.start}-{displayRange.end}</span>}
{displayRange && (
<span>
{t('inlineComment.range.lines', { start: displayRange.start, end: displayRange.end })}
</span>
)}
</div>
)}
@@ -141,7 +147,7 @@ export function InlineCommentInput({
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Add a comment... (Cmd+Enter to save)"
placeholder={t('inlineComment.input.placeholder')}
outerClassName="rounded-[var(--radius-xl)] bg-[var(--surface-subtle)] ring-1 ring-inset ring-border/60 focus-within:ring-2 focus-within:ring-[var(--interactive-focus-ring)]"
className="min-h-[80px] px-3 py-2.5 text-sm resize-y"
/>
@@ -155,7 +161,7 @@ export function InlineCommentInput({
onTouchStart={(e) => e.stopPropagation()}
className="h-8 text-muted-foreground hover:text-foreground"
>
Cancel
{t('inlineComment.actions.cancel')}
</Button>
<Button
size="sm"
@@ -169,7 +175,7 @@ export function InlineCommentInput({
color: currentTheme?.colors?.status?.successForeground,
}}
>
{isEditing ? 'Save' : 'Comment'}
{isEditing ? t('inlineComment.actions.save') : t('inlineComment.actions.comment')}
</Button>
</div>
</div>
@@ -2,6 +2,7 @@ import React from 'react';
import { toast } from '@/components/ui';
import { useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentSource } from '@/stores/useInlineCommentDraftStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useI18n } from '@/lib/i18n';
type LineRangeBase = {
start: number;
@@ -46,6 +47,7 @@ export const normalizeLineRange = <TRange extends LineRangeBase>(range: TRange):
export function useInlineCommentController<TRange extends LineRangeBase>(
options: UseInlineCommentControllerOptions<TRange>
) {
const { t } = useI18n();
const { source, fileLabel, language, getCodeForRange, toStoreRange, fromDraftRange } = options;
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
@@ -100,7 +102,7 @@ export function useInlineCommentController<TRange extends LineRangeBase>(
if (!targetRange || !trimmedText || !fileLabel) return;
if (!sessionKey) {
toast.error('Select a session to save comment');
toast.error(t('inlineComment.toast.selectSessionToSave'));
return;
}
@@ -133,7 +135,7 @@ export function useInlineCommentController<TRange extends LineRangeBase>(
}
reset();
}, [addDraft, editingDraftId, fileLabel, getCodeForRange, language, reset, selection, sessionKey, source, toStoreRange, updateDraft]);
}, [addDraft, editingDraftId, fileLabel, getCodeForRange, language, reset, selection, sessionKey, source, t, toStoreRange, updateDraft]);
return {
sessionKey,
@@ -37,6 +37,7 @@ import { cn } from '@/lib/utils';
import { toast } from '@/components/ui';
import { isTauriShell, isDesktopShell } from '@/lib/desktop';
import { useUIStore } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n';
import {
desktopHostProbe,
desktopHostsGet,
@@ -102,12 +103,17 @@ const statusDotClass = (status: HostProbeResult['status'] | null): string => {
return 'bg-muted-foreground/40';
};
const statusLabel = (status: HostProbeResult['status'] | null): string => {
if (status === 'ok') return 'Connected';
if (status === 'auth') return 'Auth required';
if (status === 'wrong-service') return 'Wrong service';
if (status === 'unreachable') return 'Unreachable';
return 'Unknown';
const statusLabelKey = (status: HostProbeResult['status'] | null):
| 'desktopHostSwitcher.status.connected'
| 'desktopHostSwitcher.status.authRequired'
| 'desktopHostSwitcher.status.wrongService'
| 'desktopHostSwitcher.status.unreachable'
| 'desktopHostSwitcher.status.unknown' => {
if (status === 'ok') return 'desktopHostSwitcher.status.connected';
if (status === 'auth') return 'desktopHostSwitcher.status.authRequired';
if (status === 'wrong-service') return 'desktopHostSwitcher.status.wrongService';
if (status === 'unreachable') return 'desktopHostSwitcher.status.unreachable';
return 'desktopHostSwitcher.status.unknown';
};
const statusIcon = (status: HostProbeResult['status'] | null) => {
@@ -120,34 +126,47 @@ const statusIcon = (status: HostProbeResult['status'] | null) => {
const sleep = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
const sshPhaseLabel = (phase: DesktopSshInstanceStatus['phase'] | undefined): string => {
const sshPhaseLabelKey = (phase: DesktopSshInstanceStatus['phase'] | undefined):
| 'desktopHostSwitcher.sshPhase.ready'
| 'desktopHostSwitcher.sshPhase.error'
| 'desktopHostSwitcher.sshPhase.reconnecting'
| 'desktopHostSwitcher.sshPhase.resolvingConfig'
| 'desktopHostSwitcher.sshPhase.checkingAuth'
| 'desktopHostSwitcher.sshPhase.connectingSsh'
| 'desktopHostSwitcher.sshPhase.probingRemote'
| 'desktopHostSwitcher.sshPhase.installing'
| 'desktopHostSwitcher.sshPhase.updating'
| 'desktopHostSwitcher.sshPhase.detectingServer'
| 'desktopHostSwitcher.sshPhase.startingServer'
| 'desktopHostSwitcher.sshPhase.forwardingPorts'
| 'desktopHostSwitcher.sshPhase.idle' => {
switch (phase) {
case 'ready':
return 'Ready';
return 'desktopHostSwitcher.sshPhase.ready';
case 'error':
return 'Error';
return 'desktopHostSwitcher.sshPhase.error';
case 'degraded':
return 'Reconnecting';
return 'desktopHostSwitcher.sshPhase.reconnecting';
case 'config_resolved':
return 'Resolving config';
return 'desktopHostSwitcher.sshPhase.resolvingConfig';
case 'auth_check':
return 'Checking auth';
return 'desktopHostSwitcher.sshPhase.checkingAuth';
case 'master_connecting':
return 'Connecting SSH';
return 'desktopHostSwitcher.sshPhase.connectingSsh';
case 'remote_probe':
return 'Probing remote';
return 'desktopHostSwitcher.sshPhase.probingRemote';
case 'installing':
return 'Installing';
return 'desktopHostSwitcher.sshPhase.installing';
case 'updating':
return 'Updating';
return 'desktopHostSwitcher.sshPhase.updating';
case 'server_detecting':
return 'Detecting server';
return 'desktopHostSwitcher.sshPhase.detectingServer';
case 'server_starting':
return 'Starting server';
return 'desktopHostSwitcher.sshPhase.startingServer';
case 'forwarding':
return 'Forwarding ports';
return 'desktopHostSwitcher.sshPhase.forwardingPorts';
default:
return 'Idle';
return 'desktopHostSwitcher.sshPhase.idle';
}
};
@@ -246,6 +265,7 @@ export function DesktopHostSwitcherDialog({
embedded = false,
onHostSwitched,
}: DesktopHostSwitcherDialogProps) {
const { t } = useI18n();
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
@@ -296,8 +316,8 @@ export function DesktopHostSwitcherDialog({
const current = React.useMemo(() => resolveCurrentHost(allHosts), [allHosts]);
const currentDefaultLabel = React.useMemo(() => {
const id = defaultHostId || LOCAL_HOST_ID;
return allHosts.find((h) => h.id === id)?.label || 'Local';
}, [allHosts, defaultHostId]);
return allHosts.find((h) => h.id === id)?.label || t('desktopHostSwitcher.instance.local');
}, [allHosts, defaultHostId, t]);
const persist = React.useCallback(async (nextHosts: DesktopHost[], nextDefaultHostId: string | null) => {
if (!isTauriShell()) return;
@@ -309,11 +329,11 @@ export function DesktopHostSwitcherDialog({
setConfigHosts(remote);
setDefaultHostId(nextDefaultHostId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
setError(err instanceof Error ? err.message : t('desktopHostSwitcher.error.failedToSave'));
} finally {
setIsSaving(false);
}
}, []);
}, [t]);
const openRemoteInstancesSettings = React.useCallback(() => {
setSettingsPage('remote-instances');
@@ -340,7 +360,7 @@ export function DesktopHostSwitcherDialog({
setSshHostIds(nextSshHostIds);
setSshStatusesById(sshStatusMap);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load');
setError(err instanceof Error ? err.message : t('desktopHostSwitcher.error.failedToLoad'));
setConfigHosts([]);
setDefaultHostId(null);
setSshHostIds({});
@@ -348,7 +368,7 @@ export function DesktopHostSwitcherDialog({
} finally {
setIsLoading(false);
}
}, []);
}, [t]);
const probeAll = React.useCallback(async (hosts: DesktopHost[]) => {
if (!isTauriShell()) return;
@@ -500,7 +520,7 @@ export function DesktopHostSwitcherDialog({
...prev,
error: message,
}));
toast.error(`SSH instance "${redactSensitiveUrl(host.label)}" failed to connect`, {
toast.error(t('desktopHostSwitcher.toast.sshFailedToConnect', { host: redactSensitiveUrl(host.label) }), {
description: message,
});
return;
@@ -520,7 +540,7 @@ export function DesktopHostSwitcherDialog({
}));
if (probe.status === 'unreachable' || probe.status === 'wrong-service') {
toast.error(`Instance "${redactSensitiveUrl(host.label)}" is unreachable`);
toast.error(t('desktopHostSwitcher.toast.instanceUnreachable', { host: redactSensitiveUrl(host.label) }));
setSwitchingHostId(null);
return;
}
@@ -534,7 +554,7 @@ export function DesktopHostSwitcherDialog({
} catch {
window.location.href = target;
}
}, [onHostSwitched, sshHostIds, sshStatusesById]);
}, [onHostSwitched, sshHostIds, sshStatusesById, t]);
const beginEdit = React.useCallback((host: DesktopHost) => {
setEditingId(host.id);
@@ -562,7 +582,7 @@ export function DesktopHostSwitcherDialog({
const url = normalizeHostUrl(editUrl);
if (!url) {
setError('Invalid URL (must be http/https)');
setError(t('desktopHostSwitcher.error.invalidUrl'));
return;
}
@@ -570,12 +590,12 @@ export function DesktopHostSwitcherDialog({
const nextHosts = configHosts.map((h) => (h.id === editingId ? { ...h, label, url } : h));
await persist(nextHosts, defaultHostId);
cancelEdit();
}, [cancelEdit, configHosts, defaultHostId, editLabel, editUrl, editingId, persist]);
}, [cancelEdit, configHosts, defaultHostId, editLabel, editUrl, editingId, persist, t]);
const addHost = React.useCallback(async () => {
const url = normalizeHostUrl(newUrl);
if (!url) {
setError('Invalid URL (must be http/https)');
setError(t('desktopHostSwitcher.error.invalidUrl'));
return;
}
const label = (newLabel || redactSensitiveUrl(url)).trim();
@@ -588,7 +608,7 @@ export function DesktopHostSwitcherDialog({
if (embedded) {
setIsAddFormOpen(false);
}
}, [configHosts, defaultHostId, embedded, newLabel, newUrl, persist]);
}, [configHosts, defaultHostId, embedded, newLabel, newUrl, persist, t]);
const deleteHost = React.useCallback(async (id: string) => {
if (id === LOCAL_HOST_ID) return;
@@ -607,11 +627,11 @@ export function DesktopHostSwitcherDialog({
if (!origin) return;
const target = toNavigationUrl(origin);
desktopOpenNewWindowAtUrl(target).catch((err: unknown) => {
toast.error('Failed to open new window', {
toast.error(t('desktopHostSwitcher.error.failedToOpenNewWindow'), {
description: err instanceof Error ? err.message : String(err),
});
});
}, []);
}, [t]);
const switchToLocal = React.useCallback(() => {
sshSwitchTokenRef.current += 1;
@@ -669,19 +689,19 @@ export function DesktopHostSwitcherDialog({
}));
});
if (readyStatus.phase === 'ready') {
toast.success(`SSH instance "${redactSensitiveUrl(host.label)}" connected`);
toast.success(t('desktopHostSwitcher.toast.sshConnected', { host: redactSensitiveUrl(host.label) }));
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message !== SSH_CONNECT_CANCELLED_ERROR) {
toast.error(`SSH instance "${redactSensitiveUrl(host.label)}" failed to connect`, {
toast.error(t('desktopHostSwitcher.toast.sshFailedToConnect', { host: redactSensitiveUrl(host.label) }), {
description: message,
});
}
} finally {
setSwitchingHostId(null);
}
}, []);
}, [t]);
if (!isDesktopShell()) {
return null;
@@ -695,10 +715,10 @@ export function DesktopHostSwitcherDialog({
<div className="flex-shrink-0 border-b border-[var(--interactive-border)] px-3 py-2">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex items-baseline gap-1.5 typography-ui-label">
<span className="font-medium text-foreground">Current</span>
<span className="font-medium text-foreground">{t('desktopHostSwitcher.header.current')}</span>
<span className="max-w-[9rem] truncate text-muted-foreground">{redactSensitiveUrl(current.label)}</span>
<span className="text-muted-foreground/50"></span>
<span className="font-medium text-foreground">Default</span>
<span className="font-medium text-foreground">{t('desktopHostSwitcher.header.default')}</span>
<span className="max-w-[9rem] truncate text-muted-foreground">{redactSensitiveUrl(currentDefaultLabel)}</span>
</div>
<button
@@ -710,7 +730,7 @@ export function DesktopHostSwitcherDialog({
)}
onClick={() => void probeAll(allHosts)}
disabled={!tauriAvailable || isLoading || isProbing}
aria-label="Refresh instances"
aria-label={t('desktopHostSwitcher.actions.refreshInstancesAria')}
>
<RiRefreshLine className={cn('h-4 w-4', isProbing && 'animate-spin')} />
</button>
@@ -720,10 +740,10 @@ export function DesktopHostSwitcherDialog({
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
<RiServerLine className="h-5 w-5" />
Instance
{t('desktopHostSwitcher.title')}
</DialogTitle>
<DialogDescription>
Switch between Local and remote OpenChamber servers
{t('desktopHostSwitcher.description')}
</DialogDescription>
</DialogHeader>
)}
@@ -731,9 +751,9 @@ export function DesktopHostSwitcherDialog({
{!embedded && (
<div className="flex items-center justify-between gap-2 flex-shrink-0">
<div className="flex items-center gap-2 min-w-0">
<span className="typography-meta text-muted-foreground">Current:</span>
<span className="typography-meta text-muted-foreground">{t('desktopHostSwitcher.header.currentColon')}</span>
<span className="typography-ui-label text-foreground truncate">{redactSensitiveUrl(current.label)}</span>
<span className="typography-meta text-muted-foreground">Current default:</span>
<span className="typography-meta text-muted-foreground">{t('desktopHostSwitcher.header.currentDefaultColon')}</span>
<span className="typography-ui-label text-foreground truncate">{redactSensitiveUrl(currentDefaultLabel)}</span>
</div>
<div className="flex items-center gap-1">
@@ -745,7 +765,7 @@ export function DesktopHostSwitcherDialog({
disabled={!tauriAvailable || isLoading || isProbing}
>
<RiRefreshLine className={cn('h-4 w-4', isProbing && 'animate-spin')} />
Refresh
{t('desktopHostSwitcher.actions.refresh')}
</Button>
</div>
</div>
@@ -753,10 +773,10 @@ export function DesktopHostSwitcherDialog({
{tauriAvailable && (
<div className="flex-shrink-0 flex items-center justify-between gap-2 px-2.5 py-1.5">
<span className="typography-micro text-muted-foreground">Need SSH instances?<br />Manage them in Settings.</span>
<span className="typography-micro text-muted-foreground">{t('desktopHostSwitcher.ssh.needInstancesHint')}</span>
<Button type="button" variant="ghost" size="sm" onClick={openRemoteInstancesSettings}>
<RiSettings3Line className="h-4 w-4" />
Remote SSH
{t('desktopHostSwitcher.actions.remoteSsh')}
</Button>
</div>
)}
@@ -764,7 +784,7 @@ export function DesktopHostSwitcherDialog({
{!tauriAvailable && (
<div className="flex-shrink-0 rounded-lg border border-border/50 bg-muted/20 p-3">
<div className="typography-meta text-muted-foreground">
Instance switcher is limited on this page. Use Local to recover.
{t('desktopHostSwitcher.state.limitedOnPage')}
</div>
</div>
)}
@@ -772,7 +792,7 @@ export function DesktopHostSwitcherDialog({
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="space-y-1">
{isLoading ? (
<div className="px-2 py-2 text-muted-foreground text-sm">Loading</div>
<div className="px-2 py-2 text-muted-foreground text-sm">{t('desktopHostSwitcher.state.loading')}</div>
) : (
allHosts.map((host) => {
const isLocal = host.id === LOCAL_HOST_ID;
@@ -784,7 +804,9 @@ export function DesktopHostSwitcherDialog({
const statusKind = isSsh ? sshPhaseToHostStatus(sshStatus?.phase) : (status?.status ?? null);
const isEditing = editingId === host.id;
const effectiveUrl = isLocal ? getLocalOrigin() : (normalizeHostUrl(host.url) || host.url);
const displayLabel = redactSensitiveUrl(host.label);
const displayLabel = host.id === LOCAL_HOST_ID
? t('desktopHostSwitcher.instance.local')
: redactSensitiveUrl(host.label);
const displayUrl = redactSensitiveUrl(effectiveUrl);
return (
@@ -803,7 +825,7 @@ export function DesktopHostSwitcherDialog({
)}
onClick={() => void handleSwitch(host)}
disabled={switchingHostId === host.id}
aria-label={`Switch to ${displayLabel}`}
aria-label={t('desktopHostSwitcher.actions.switchToAria', { instance: displayLabel })}
>
<span className={cn('h-2 w-2 rounded-full flex-shrink-0', statusDotClass(statusKind))} />
<div className="flex-1 min-w-0">
@@ -817,13 +839,15 @@ export function DesktopHostSwitcherDialog({
</span>
)}
{isActive && (
<span className="typography-micro text-muted-foreground">Current</span>
<span className="typography-micro text-muted-foreground">{t('desktopHostSwitcher.header.current')}</span>
)}
<span className="inline-flex items-center gap-1 typography-micro text-muted-foreground">
{statusIcon(statusKind)}
<span>
{isSsh ? sshPhaseLabel(sshStatus?.phase) : statusLabel(status?.status ?? null)}
{!isSsh && status?.status === 'ok' && typeof status.latencyMs === 'number' ? ` · ${Math.max(0, Math.round(status.latencyMs))}ms ping` : ''}
{isSsh ? t(sshPhaseLabelKey(sshStatus?.phase)) : t(statusLabelKey(status?.status ?? null))}
{!isSsh && status?.status === 'ok' && typeof status.latencyMs === 'number'
? t('desktopHostSwitcher.status.ping', { ms: Math.max(0, Math.round(status.latencyMs)) })
: ''}
</span>
</span>
</div>
@@ -840,7 +864,7 @@ export function DesktopHostSwitcherDialog({
<button
type="button"
className="h-8 w-8 rounded-md inline-flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-interactive-hover transition-colors"
aria-label="Instance actions"
aria-label={t('desktopHostSwitcher.actions.instanceActionsAria')}
disabled={isSaving}
onClick={(e) => e.stopPropagation()}
>
@@ -856,7 +880,7 @@ export function DesktopHostSwitcherDialog({
disabled={isSaving}
>
<RiPencilLine className="h-4 w-4 mr-1" />
Edit
{t('desktopHostSwitcher.actions.edit')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
@@ -867,7 +891,7 @@ export function DesktopHostSwitcherDialog({
disabled={isSaving}
>
<RiDeleteBinLine className="h-4 w-4 mr-1" />
Delete
{t('desktopHostSwitcher.actions.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -894,7 +918,7 @@ export function DesktopHostSwitcherDialog({
}}
>
{switchingHostId === host.id ? <RiLoader4Line className="h-3.5 w-3.5 animate-spin" /> : <RiPlug2Line className="h-3.5 w-3.5" />}
Connect
{t('desktopHostSwitcher.actions.connect')}
</Button>
) : (
<div
@@ -915,14 +939,14 @@ export function DesktopHostSwitcherDialog({
: 'text-muted-foreground/60 hover:text-primary/80',
)}
onClick={() => void setDefault(host.id)}
aria-label={isDefault ? 'Default instance' : 'Set as default'}
aria-label={isDefault ? t('desktopHostSwitcher.actions.defaultInstanceAria') : t('desktopHostSwitcher.actions.setAsDefaultAria')}
disabled={isSaving || (!isDefault && (statusKind === 'unreachable' || statusKind === 'wrong-service'))}
>
{isDefault ? <RiStarFill className="h-4 w-4" /> : <RiStarLine className="h-4 w-4" />}
</button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>
{isDefault ? 'Default' : 'Set as default'}
{isDefault ? t('desktopHostSwitcher.header.default') : t('desktopHostSwitcher.actions.setAsDefault')}
</TooltipContent>
</Tooltip>
@@ -941,13 +965,15 @@ export function DesktopHostSwitcherDialog({
openInNewWindow(host);
}}
disabled={statusKind === 'unreachable' || statusKind === 'wrong-service'}
aria-label="Open in new window"
aria-label={t('desktopHostSwitcher.actions.openInNewWindowAria')}
>
<RiWindowLine className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>
{(statusKind === 'unreachable' || statusKind === 'wrong-service') ? 'Instance unreachable' : 'Open in new window'}
{(statusKind === 'unreachable' || statusKind === 'wrong-service')
? t('desktopHostSwitcher.state.instanceUnreachable')
: t('desktopHostSwitcher.actions.openInNewWindow')}
</TooltipContent>
</Tooltip>
</div>
@@ -961,14 +987,14 @@ export function DesktopHostSwitcherDialog({
{tauriAvailable && editingId && editingId !== LOCAL_HOST_ID && (
<div className="flex-shrink-0 rounded-lg border border-border/50 bg-muted/20 p-3">
<div className="flex items-center justify-between gap-2">
<div className="typography-ui-label font-medium text-foreground">Edit instance</div>
<div className="typography-ui-label font-medium text-foreground">{t('desktopHostSwitcher.edit.title')}</div>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={cancelEdit} disabled={isSaving}>
Cancel
{t('desktopHostSwitcher.actions.cancel')}
</Button>
<Button type="button" size="sm" onClick={() => void commitEdit()} disabled={isSaving}>
{isSaving ? <RiLoader4Line className="h-4 w-4 animate-spin" /> : null}
Save
{t('desktopHostSwitcher.actions.save')}
</Button>
</div>
</div>
@@ -977,14 +1003,14 @@ export function DesktopHostSwitcherDialog({
value={editLabel}
onChange={(e) => setEditLabel(e.target.value)}
onKeyDown={stopDropdownTypeahead}
placeholder="Label"
placeholder={t('desktopHostSwitcher.field.labelPlaceholder')}
disabled={isSaving}
/>
<Input
value={editUrl}
onChange={(e) => setEditUrl(e.target.value)}
onKeyDown={stopDropdownTypeahead}
placeholder="https://host:port"
placeholder={t('desktopHostSwitcher.field.urlPlaceholder')}
disabled={isSaving}
/>
</div>
@@ -1000,7 +1026,7 @@ export function DesktopHostSwitcherDialog({
disabled={!tauriAvailable || isSaving}
>
<RiAddLine className="h-4 w-4" />
<span className="typography-ui-label">Add instance</span>
<span className="typography-ui-label">{t('desktopHostSwitcher.actions.addInstance')}</span>
</button>
</div>
) : (
@@ -1011,7 +1037,7 @@ export function DesktopHostSwitcherDialog({
: 'rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-2.5'
)}>
<div className="flex items-center justify-between gap-2">
<div className="typography-ui-label font-medium text-foreground">Add instance</div>
<div className="typography-ui-label font-medium text-foreground">{t('desktopHostSwitcher.add.title')}</div>
<div className="flex items-center gap-2">
{embedded && (
<Button
@@ -1021,7 +1047,7 @@ export function DesktopHostSwitcherDialog({
onClick={() => setIsAddFormOpen(false)}
disabled={isSaving}
>
Cancel
{t('desktopHostSwitcher.actions.cancel')}
</Button>
)}
<Button
@@ -1031,7 +1057,7 @@ export function DesktopHostSwitcherDialog({
disabled={!tauriAvailable || isSaving || !newUrl.trim()}
>
{isSaving ? <RiLoader4Line className="h-4 w-4 animate-spin" /> : null}
Add
{t('desktopHostSwitcher.actions.add')}
</Button>
</div>
</div>
@@ -1040,14 +1066,14 @@ export function DesktopHostSwitcherDialog({
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
onKeyDown={stopDropdownTypeahead}
placeholder="Label (optional)"
placeholder={t('desktopHostSwitcher.field.labelOptionalPlaceholder')}
disabled={!tauriAvailable || isSaving}
/>
<Input
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
onKeyDown={stopDropdownTypeahead}
placeholder="https://host:port"
placeholder={t('desktopHostSwitcher.field.urlPlaceholder')}
disabled={!tauriAvailable || isSaving}
/>
</div>
@@ -1079,12 +1105,12 @@ export function DesktopHostSwitcherDialog({
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RiLoader4Line className={cn('h-4 w-4', !sshSwitchModal.error && 'animate-spin')} />
Connecting to {sshSwitchModal.hostLabel || 'SSH instance'}
{t('desktopHostSwitcher.ssh.connectingTo', { host: sshSwitchModal.hostLabel || t('desktopHostSwitcher.ssh.instanceFallback') })}
</DialogTitle>
<DialogDescription>
{sshSwitchModal.error
? sshSwitchModal.error
: sshSwitchModal.detail || sshPhaseLabel(sshSwitchModal.phase)}
: sshSwitchModal.detail || t(sshPhaseLabelKey(sshSwitchModal.phase))}
</DialogDescription>
</DialogHeader>
{sshSwitchModal.error ? (
@@ -1095,7 +1121,7 @@ export function DesktopHostSwitcherDialog({
variant="outline"
onClick={switchToLocal}
>
Switch to Local
{t('desktopHostSwitcher.actions.switchToLocal')}
</Button>
<Button
type="button"
@@ -1103,7 +1129,7 @@ export function DesktopHostSwitcherDialog({
onClick={retrySshSwitch}
disabled={!sshSwitchModal.hostId}
>
Retry
{t('desktopHostSwitcher.actions.retry')}
</Button>
</div>
) : null}
@@ -1139,6 +1165,7 @@ type DesktopHostSwitcherButtonProps = {
};
export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHostSwitcherButtonProps) {
const { t } = useI18n();
const [open, setOpen] = React.useState(false);
const [label, setLabel] = React.useState('Local');
const [status, setStatus] = React.useState<HostProbeResult['status'] | null>(null);
@@ -1251,7 +1278,7 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
}
if (cancelled) return;
setLabel(redactSensitiveUrl(current.label || 'Instance'));
setLabel(redactSensitiveUrl(current.label || t('desktopHostSwitcher.instance.fallback')));
const normalized = normalizeHostUrl(current.url);
if (!normalized) {
setStatus(null);
@@ -1262,7 +1289,7 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
setStatus(res.status);
} catch {
if (!cancelled) {
setLabel('Instance');
setLabel(t('desktopHostSwitcher.instance.fallback'));
setStatus(null);
}
}
@@ -1290,13 +1317,13 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
const fallbackLabel = typeof window !== 'undefined' && window.location.hostname
? window.location.hostname
: 'Instance';
: t('desktopHostSwitcher.instance.fallback');
const effectiveLabel = isCurrentlyLocal
? 'Local'
: label === 'Local'
? fallbackLabel
: label;
? t('desktopHostSwitcher.instance.local')
: label === 'Local'
? fallbackLabel
: label;
const safeEffectiveLabel = redactSensitiveUrl(effectiveLabel);
return (
@@ -1306,7 +1333,7 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
<button
type="button"
onClick={() => setOpen(true)}
aria-label="Switch instance"
aria-label={t('desktopHostSwitcher.actions.switchInstanceAria')}
data-oc-host-switcher
className={cn(headerIconButtonClass, 'relative w-auto px-3')}
>
@@ -1319,12 +1346,12 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
'pointer-events-none absolute top-1.5 right-1.5 h-1.5 w-1.5 rounded-full',
statusDotClass(status)
)}
aria-label="Instance status"
aria-label={t('desktopHostSwitcher.statusAria')}
/>
</button>
</TooltipTrigger>
<TooltipContent>
<p>Instance</p>
<p>{t('desktopHostSwitcher.title')}</p>
</TooltipContent>
</Tooltip>
<DesktopHostSwitcherDialog open={open} onOpenChange={setOpen} />
@@ -1347,11 +1374,11 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
>
<DialogContent className="w-[min(30rem,calc(100vw-2rem))] max-w-none">
<DialogHeader>
<DialogTitle>Default SSH instance unavailable</DialogTitle>
<DialogTitle>{t('desktopHostSwitcher.startup.title')}</DialogTitle>
<DialogDescription>
{startupSshModal.connecting
? `Connecting to ${startupSshModal.hostLabel || 'SSH instance'}...`
: startupSshModal.error || 'Failed to connect the default SSH instance.'}
? t('desktopHostSwitcher.startup.connectingTo', { host: startupSshModal.hostLabel || t('desktopHostSwitcher.ssh.instanceFallback') })
: startupSshModal.error || t('desktopHostSwitcher.startup.failed')}
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2">
@@ -1362,7 +1389,7 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
onClick={() => void switchStartupToLocal()}
disabled={startupSshModal.connecting}
>
Switch to Local
{t('desktopHostSwitcher.actions.switchToLocal')}
</Button>
<Button
type="button"
@@ -1371,7 +1398,7 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
disabled={startupSshModal.connecting || !startupSshModal.hostId}
>
{startupSshModal.connecting ? <RiLoader4Line className="h-4 w-4 animate-spin" /> : null}
Retry
{t('desktopHostSwitcher.actions.retry')}
</Button>
</div>
</DialogContent>
@@ -1382,6 +1409,7 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
export function DesktopHostSwitcherInline() {
const [open, setOpen] = React.useState(false);
const { t } = useI18n();
if (!isDesktopShell()) {
return null;
@@ -1398,7 +1426,7 @@ export function DesktopHostSwitcherInline() {
onClick={() => setOpen(true)}
>
<RiServerLine className="h-4 w-4" />
Switch instance
{t('desktopHostSwitcher.actions.switchInstance')}
</Button>
<DesktopHostSwitcherDialog open={open} onOpenChange={setOpen} />
</>
@@ -13,6 +13,7 @@ import { isDesktopLocalOriginActive, isTauriShell, openDesktopPath, openDesktopP
import { DEFAULT_OPEN_IN_APP_ID, OPEN_IN_APPS } from '@/lib/openInApps';
import { useOpenInAppsStore, type OpenInAppOption } from '@/stores/useOpenInAppsStore';
import { RiArrowDownSLine, RiCheckLine, RiFileCopyLine, RiRefreshLine } from '@remixicon/react';
import { useI18n } from '@/lib/i18n';
const FINDER_DEFAULT_ICON_DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAeGVYSWZNTQAqAAAACAAEARoABQAAAAEAAAA+ARsABQAAAAEAAABGASgAAwAAAAEAAgAAh2kABAAAAAEAAABOAAAAAAAAAJAAAAABAAAAkAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAIAAAAAB+C9pSAAAACXBIWXMAABYlAAAWJQFJUiTwAAABnWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczpleGlmPSJodHRwOi8vbnMuYWRvYmUuY29tL2V4aWYvMS4wLyI+CiAgICAgICAgIDxleGlmOlBpeGVsWERpbWVuc2lvbj4yNTY8L2V4aWY6UGl4ZWxYRGltZW5zaW9uPgogICAgICAgICA8ZXhpZjpQaXhlbFlEaW1lbnNpb24+MjU2PC9leGlmOlBpeGVsWURpbWVuc2lvbj4KICAgICAgPC9yZGY6RGVzY3JpcHRpb24+CiAgIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+Cl6wHhsAAAXaSURBVFgJ7VddbBRVFP5mdme6dOnu2tZawOAPjfxU+bGQYCRAsvw8qNGEQPTRJ0I0amL0wfjgA/HBR8KLD8YgDxJEUkVFxSYYTSRii6DQQCMGIQqlW7p0u+zMzo/fuTuzO9Nt0Td94CRn7pl7z5zznZ977y5wh/7jDGiz+T979qD5Ujbfd90xlll+stOF1uI40B1+4HhkjnZk9CgLQ9iXp2/BdcbgVc/h0sAgduywudJEMwLY9Of4ugtW5p3CpL7W1jTN88VmjdQYvnDKF1mczkYuNZLeCVg3X8fa9u+nqzUB2HRpdN2pSseRQknPoUL1Jo2ICTrPGcCzdwPdHENcAnicKRqcAk7cpL5J1r0JlAtPYV1XDETM/FtH3m19r+f5by+XjNX/xnmCX3/cCzydi4CKiC7lw+PArhGgoPPFq/6E0+9vwM6d5VBNpuv03cLNfeNTRh9KnJIiV2/PvSngycC5RD+dE5zb3g7s6QESzAZc2l6wuY9SnWIAxv10r81uU85Vt1FvtpEtlc/SMFUkUofeZ2IBta0DWDmXgkfbyTRz1qAYAMczOz3p1elOxYPyEllj421hdELViPO6Kudk3ia3UGe5ABDbvtnJZ52SdYmCZ3stdeexBabFdeAbYopEowtagVUZqFapBrtAGqpiVaFrGgyjZlrmTD5yEqoEJj4iFMuA62i6L3WPZkAiuHgarZ/vbWSBkTzO2rfTR4XOJVJhjfX44MBn+OTocVWbcF5MalxXPeVL6zYonoGo44YOtDI7qHC1lkL5nHnOc+tJRi3K6iygLNGMjt1A1XVV6iUzOvVtAvMlS2I/yBYlRf8MgA6szmXQ1jDfKhSgjft6DRtrkgarAiAw5nI9v2WDSn+Zxfd9DawGxIlPPQUg0A2HGABfEIYlCDU4+q0d8O+jRzHCCFYy+nu4BaeYAoksBCDrPYsXQQ6iitgiSQaS1FHHtMzFil4DpxTl4UhORSn4WOaaiGsbu4iFRkMnYQlEV0oSJQGQ4FyYgSRDjpqPZcCR6EOOWonIEsBqArAIQOMLzw0VXRRERF2VoA6Atk1+MzsASekMJYgaFEeHR4Cr85lNGntYzgKCYd/NSNIDCXr0ZJ2jwTsjSvEMzFQCCVmKHBRahn2DNb4rDRx8pnbXOOIg0JELLMHOF1AUkaRj1V8c2TookkMS83WK9QCVpRwtf5wCykQWRKDyJ44Ytc452QUV6inmN9IDIv/6y2+YLDuqTywBEHxv8rsoxQC4Fpf4cZ2pbJ4/huxXr0EvFmoRCrAIVymLQ3Eid0GJYPsPfISBLwdwi79YQnCqBNS7LQDP5qYSAKEDypOrX4WVWYLsFy+i9cwh6CUmUKIJI2Gq5cSbnLLw849D2Ld3L4olC1u3P0c1ow5Ozgixa3puWChONG1D3eLZUQOglvng+Vp5dBfseesx5/yHyI4cBTL3wsssRGs2g6/ppHijiMLoNSSMNHofy6Nn6SPsAR02nUoTtrDTSrdoi8CTni55rlOsCf1ypaDxlFMNU1epCV5XL6Y6dmOq+BeS48NIlq7Anpjg5dOFbPdDWLQyj/aubnUKSkMKi3NhkUd4kieYtbRbYS0bFAOQKI8NO363z1RJHmamtnlwhGksxV2w/gl29WRtm8kWtWUnRShLnQvXgDOXmLg2HzlvbDiyHD8Y517YP2i4FtueFPbB9FFqKcyobk4A5y7zquUFa7IXojyHoeXmAFcY755vaI6A56Xsofm/7+cmblBTpOldQ5vs3PJDVS+RVSAaus2SpJTO80t4NTNSOQfCDrtFkBevA0ME6HGvPdDpFlekzm7rf3nFQNRQEwBZTL9warObWfx21Uv1+fx1ERqVNampGoOHpF1tsdp07RnoGMxK1vT97rbK4IP6+Tc+fWXVsahaYGL6VO09d//GXHXr7jVeqmuppqU6ff4x0RO6lqRxgxHJpWKSlcw5eWfjq5rq/CdhaL5l6JWxjDc6bP7w5sn+/uMs2B36H2bgb6v9raK0+o9IAAAAAElFTkSuQmCC';
const TERMINAL_DEFAULT_ICON_DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAeGVYSWZNTQAqAAAACAAEARoABQAAAAEAAAA+ARsABQAAAAEAAABGASgAAwAAAAEAAgAAh2kABAAAAAEAAABOAAAAAAAAAJAAAAABAAAAkAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAIAAAAAB+C9pSAAAACXBIWXMAABYlAAAWJQFJUiTwAAABnWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczpleGlmPSJodHRwOi8vbnMuYWRvYmUuY29tL2V4aWYvMS4wLyI+CiAgICAgICAgIDxleGlmOlBpeGVsWERpbWVuc2lvbj4yNTY8L2V4aWY6UGl4ZWxYRGltZW5zaW9uPgogICAgICAgICA8ZXhpZjpQaXhlbFlEaW1lbnNpb24+MjU2PC9leGlmOlBpeGVsWURpbWVuc2lvbj4KICAgICAgPC9yZGY6RGVzY3JpcHRpb24+CiAgIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+Cl6wHhsAAAQzSURBVFgJ7VZNbBNHFH67Xv9RxwnBDqlUoQglcZK6qSIEJIQWAYJQoVY9IE5RTzn20FMvqdpDesq9B24+NdwthAJCkZChJg1JSOXYQIwQKQIaBdtENbs73t2+N8miGWOcpFHUHniyd97OvJ9v3nv7ZgDe038cAeVd/jOZjC94sKdfU+Bj24G9igpexwYPyiu2bauKqqqirkOTqmrjnIOyFsoyUKDocSCj/7mU7ujoMER5l68JYOFZ4YSiwPjd9O0jjx7ch1KhAJZVAcdx0LxDv3XetYKjggr4I4bzHo8G4aYmONjZBYf6+2dUzfd9PNowJajUZmef/PX5zcWl0rmvvnbQHrra+f/M+S+dqYXs2t3Hz09Ve5UicCmZ3NPb1Zv66btv+65dSULA64WGxkbw+Xx8V9XK9d4pWowxeFUqgW6acHroC/j5l0sLD/PZY98MDf3t6mouQ+On3X1H7/2e7rtOztHpgbY2+CAUgperq+D3+7cNgtLSEA7D0+VluDF5FS7cSff2HT56DF1dd/3KhQTWJ/lclsc8jIrk9IfRURgZGQEvRqNSWa8D2t1W/liXXK8Ro0i0lF0ExaPEXec0SgAqhrm3VCzwdS9GQNd1GBsbg0AgAIlEAlpbW7EYLVF/U56AagieiGwbuhERlSQApmEE8c/XKXxU0fF4HNowFfPz81Aul7edBjLGbeHITANsZga4g42HVAM2Y74KM/kSIQ/izgcHB2FiYgJmZmZ4MZpYULRG5PF4+Bx/2cLDxuhhYUqFLwGoWCaQEBGhNjAa4+Pj/J3SQA6pHpqbm/kcNitIJpOgaZIZvlbrQbZNJvcjSZOZDKhwRKLic4l2Pjc3B8FgkE+trKxAVUN0RWuOZNtCHyJJACj/bgREIZcnA9PT029SQM63unuywSOwUWOuTQmAhfmnlluPxIjUk6u1RrbJh0jyV0Ap2OZnJhrbjOcRqEqBBMDCAtltAORDJAkAVj2mWS5CUXinPDUx+oxFkgBYjO0qANu2wKoqQgkAfgW7C4AiYMmfoQSgwpjj7GYRUh/Q66SAmdisNxql227FfP1bXrRlVExdtCNHwDRLdPkgwmi8OUREhe3y1NLJFpEfbWMNvBRtSI2o+KqYi+zbx4NQwptMCO8E1HjEHYjKm/HknG5FZIsCG4lEoLS2lhP1JAB3bt1KH//s+GJPd3dPJpvlN5kwXiYIhHukisr1eAItXsm6YzGItrTcn5+dvS3qSQBSqVQhFouNnj039CsaCC7mcqDjgbNT6op1AtrU8Wo3Ojk5KaVAOptdR8PDwxf3t7SMvXjxvJNOPP31a35Krt8CXKl3j2SUDip/IAjRaBRaP9z/cHW18GMikbhcrVUTAAm1t7d/NDAwcDIUCvVqmtqkyLe3ajtvvTtg4x3SLpbLa3+kUr9N5fP55beE3k/8HyLwDx2/HIx7q3WfAAAAAElFTkSuQmCC';
@@ -73,6 +74,7 @@ type OpenInAppButtonProps = {
};
export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps) => {
const { t } = useI18n();
const selectedAppId = useOpenInAppsStore((state) => state.selectedAppId);
const availableApps = useOpenInAppsStore((state) => state.availableApps);
const isCacheStale = useOpenInAppsStore((state) => state.isCacheStale);
@@ -124,7 +126,7 @@ export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps)
if (!result.ok) {
return;
}
toast.success('Path copied to clipboard');
toast.success(t('openInApp.toast.pathCopied'));
};
return (
@@ -143,7 +145,7 @@ export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps)
'inline-flex h-full items-center gap-2 px-3 typography-ui-label font-medium',
'text-foreground hover:bg-interactive-hover transition-colors'
)}
aria-label={`Open in ${selectedApp.label}`}
aria-label={t('openInApp.actions.openInAria', { app: selectedApp.label })}
>
<AppIcon
label={selectedApp.label}
@@ -151,7 +153,7 @@ export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps)
fallbackIconDataUrl={selectedApp.fallbackIconDataUrl}
/>
<span className={cn('header-open-label', isScanning ? 'animate-pulse text-muted-foreground' : undefined)}>
Open
{t('openInApp.actions.open')}
</span>
</button>
<DropdownMenu>
@@ -163,7 +165,7 @@ export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps)
'border-l border-[var(--interactive-border)] text-muted-foreground',
'hover:bg-interactive-hover hover:text-foreground transition-colors'
)}
aria-label="Choose app to open"
aria-label={t('openInApp.actions.chooseAppAria')}
>
<RiArrowDownSLine className="h-4 w-4" />
</button>
@@ -175,7 +177,7 @@ export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps)
>
<DropdownMenuItem className="flex items-center gap-2" onClick={() => void handleCopyPath()}>
<RiFileCopyLine className="h-4 w-4" />
<span className="typography-ui-label text-foreground">Copy Path</span>
<span className="typography-ui-label text-foreground">{t('openInApp.actions.copyPath')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
{availableApps.map((app) => {
@@ -204,7 +206,7 @@ export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps)
onClick={() => void loadInstalledApps(true)}
>
<RiRefreshLine className="h-4 w-4" />
<span className="typography-ui-label text-foreground">Refresh Apps</span>
<span className="typography-ui-label text-foreground">{t('openInApp.actions.refreshApps')}</span>
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
@@ -2,6 +2,7 @@ import React from 'react';
import { RiCloseLine, RiFullscreenExitLine, RiFullscreenLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n';
const BOTTOM_DOCK_MIN_HEIGHT = 180;
const BOTTOM_DOCK_MAX_HEIGHT = 640;
@@ -14,6 +15,7 @@ interface BottomTerminalDockProps {
}
export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen, isMobile, children }) => {
const { t } = useI18n();
const bottomTerminalHeight = useUIStore((state) => state.bottomTerminalHeight);
const isFullscreen = useUIStore((state) => state.isBottomTerminalExpanded);
const setBottomTerminalHeight = useUIStore((state) => state.setBottomTerminalHeight);
@@ -153,7 +155,7 @@ export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen,
onPointerDown={handlePointerDown}
role="separator"
aria-orientation="horizontal"
aria-label="Resize terminal panel"
aria-label={t('terminalView.bottomDock.resizeAria')}
/>
)}
@@ -163,8 +165,8 @@ export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen,
type="button"
onClick={toggleFullscreen}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--surface-muted-foreground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
title={isFullscreen ? 'Restore terminal panel height' : 'Expand terminal panel'}
aria-label={isFullscreen ? 'Restore terminal panel height' : 'Expand terminal panel'}
title={isFullscreen ? t('terminalView.bottomDock.restoreTitle') : t('terminalView.bottomDock.expandTitle')}
aria-label={isFullscreen ? t('terminalView.bottomDock.restoreAria') : t('terminalView.bottomDock.expandAria')}
>
{isFullscreen ? <RiFullscreenExitLine className="h-5 w-5" /> : <RiFullscreenLine className="h-5 w-5" />}
</button>
@@ -172,8 +174,8 @@ export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen,
type="button"
onClick={() => setBottomTerminalOpen(false)}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--surface-muted-foreground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
title="Close terminal panel"
aria-label="Close terminal panel"
title={t('terminalView.bottomDock.closeTitle')}
aria-label={t('terminalView.bottomDock.closeAria')}
>
<RiCloseLine className="h-6 w-6" />
</button>
@@ -8,6 +8,7 @@ import { DiffView, FilesView, PlanView } from '@/components/views';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
import { useUIStore } from '@/stores/useUIStore';
import { ContextPanelContent } from './ContextSidebarTab';
@@ -16,6 +17,7 @@ const CONTEXT_PANEL_MIN_WIDTH = 360;
const CONTEXT_PANEL_MAX_WIDTH = 1400;
const CONTEXT_PANEL_DEFAULT_WIDTH = 600;
const CONTEXT_TAB_LABEL_MAX_CHARS = 24;
type TranslateFn = ReturnType<typeof useI18n>['t'];
const normalizeDirectoryKey = (value: string): string => {
if (!value) return '';
@@ -56,12 +58,15 @@ const getRelativePathLabel = (filePath: string | null, directory: string): strin
return normalizedFile;
};
const getModeLabel = (mode: 'diff' | 'file' | 'context' | 'plan' | 'chat'): string => {
if (mode === 'chat') return 'Chat';
if (mode === 'file') return 'Files';
if (mode === 'diff') return 'Diff';
if (mode === 'plan') return 'Plan';
return 'Context';
const getModeLabel = (
mode: 'diff' | 'file' | 'context' | 'plan' | 'chat',
t: TranslateFn
): string => {
if (mode === 'chat') return t('contextPanel.mode.chat');
if (mode === 'file') return t('contextPanel.mode.files');
if (mode === 'diff') return t('contextPanel.mode.diff');
if (mode === 'plan') return t('contextPanel.mode.plan');
return t('contextPanel.mode.context');
};
const getFileNameFromPath = (path: string | null): string | null => {
@@ -82,16 +87,19 @@ const getFileNameFromPath = (path: string | null): string | null => {
return segments[segments.length - 1] || null;
};
const getTabLabel = (tab: { mode: 'diff' | 'file' | 'context' | 'plan' | 'chat'; label: string | null; targetPath: string | null }): string => {
const getTabLabel = (
tab: { mode: 'diff' | 'file' | 'context' | 'plan' | 'chat'; label: string | null; targetPath: string | null },
t: TranslateFn
): string => {
if (tab.label) {
return tab.label;
}
if (tab.mode === 'file') {
return getFileNameFromPath(tab.targetPath) || 'Files';
return getFileNameFromPath(tab.targetPath) || t('contextPanel.mode.files');
}
return getModeLabel(tab.mode);
return getModeLabel(tab.mode, t);
};
const getTabIcon = (tab: { mode: 'diff' | 'file' | 'context' | 'plan' | 'chat'; targetPath: string | null }): React.ReactNode | undefined => {
@@ -156,6 +164,7 @@ const truncateTabLabel = (value: string, maxChars: number): string => {
};
export const ContextPanel: React.FC = () => {
const { t } = useI18n();
const effectiveDirectory = useEffectiveDirectory() ?? '';
const directoryKey = React.useMemo(() => normalizeDirectoryKey(effectiveDirectory), [effectiveDirectory]);
@@ -395,7 +404,7 @@ export const ContextPanel: React.FC = () => {
}, [darkThemeId, lightThemeId, postEmbeddedVisibilityToChats, postThemeSyncToEmbeddedChat, tabs, themeMode]);
const tabItems = React.useMemo(() => tabs.map((tab) => {
const rawLabel = getTabLabel(tab);
const rawLabel = getTabLabel(tab, t);
const label = truncateTabLabel(rawLabel, CONTEXT_TAB_LABEL_MAX_CHARS);
const tabPathLabel = getRelativePathLabel(tab.targetPath, effectiveDirectory);
return {
@@ -403,9 +412,9 @@ export const ContextPanel: React.FC = () => {
label,
icon: getTabIcon(tab),
title: tabPathLabel ? `${rawLabel}: ${tabPathLabel}` : rawLabel,
closeLabel: `Close ${label} tab`,
closeLabel: t('contextPanel.tab.closeTabAria', { label }),
};
}), [effectiveDirectory, tabs]);
}), [effectiveDirectory, t, tabs]);
const activeNonChatContent = activeTab?.mode === 'diff'
? <DiffView hideStackedFileSidebar stackedDefaultCollapsedAll hideFileSelector pinSelectedFileHeaderToTopOnNavigate showOpenInEditorAction />
@@ -459,8 +468,8 @@ export const ContextPanel: React.FC = () => {
size="sm"
onClick={handleToggleExpanded}
className="h-7 w-7 p-0"
title={isExpanded ? 'Collapse panel' : 'Expand panel'}
aria-label={isExpanded ? 'Collapse panel' : 'Expand panel'}
title={isExpanded ? t('contextPanel.actions.collapsePanel') : t('contextPanel.actions.expandPanel')}
aria-label={isExpanded ? t('contextPanel.actions.collapsePanel') : t('contextPanel.actions.expandPanel')}
>
{isExpanded ? <RiFullscreenExitLine className="h-3.5 w-3.5" /> : <RiFullscreenLine className="h-3.5 w-3.5" />}
</Button>
@@ -470,8 +479,8 @@ export const ContextPanel: React.FC = () => {
size="sm"
onClick={handleClose}
className="h-7 w-7 p-0"
title="Close panel"
aria-label="Close panel"
title={t('contextPanel.actions.closePanel')}
aria-label={t('contextPanel.actions.closePanel')}
>
<RiCloseLine className="h-3.5 w-3.5" />
</Button>
@@ -525,7 +534,7 @@ export const ContextPanel: React.FC = () => {
onPointerCancel={handleResizeEnd}
role="separator"
aria-orientation="vertical"
aria-label="Resize context panel"
aria-label={t('contextPanel.actions.resizePanelAria')}
/>
)}
{header}
@@ -557,7 +566,7 @@ export const ContextPanel: React.FC = () => {
chatFrameRefs.current.set(tab.id, node);
}}
src={src}
title={`Session chat ${sessionID}`}
title={t('contextPanel.iframe.sessionChatTitle', { sessionID })}
className={cn(
'absolute inset-0 h-full w-full border-0 bg-background',
activeChatTabID === tab.id ? 'block' : 'hidden'
@@ -10,6 +10,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessions, useSessionMessageRecords } from '@/sync/sync-context';
import { copyTextToClipboard } from '@/lib/clipboard';
import { useI18n } from '@/lib/i18n';
type SessionMessage = { info: Message; parts: Part[] };
@@ -230,14 +231,13 @@ const formatMoney = (value: number): string => {
const formatDateTime = (timestamp: number | null): string => {
if (!timestamp || !Number.isFinite(timestamp)) return '-';
const value = new Date(timestamp).toLocaleString(undefined, {
return new Date(timestamp).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
return value.replace(/, (\d{1,2}:\d{2} [AP]M)$/, ' at $1');
};
const formatMessageDateMeta = (timestamp: number | null): string => {
@@ -271,6 +271,7 @@ const resolveProviderAndModel = (
};
export const ContextPanelContent: React.FC = () => {
const { t } = useI18n();
const { currentTheme } = useThemeSystem();
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
const [expandedRawMessages, setExpandedRawMessages] = React.useState<Record<string, boolean>>({});
@@ -367,7 +368,7 @@ export const ContextPanelContent: React.FC = () => {
: null;
return {
sessionTitle: currentSession?.title || 'Untitled Session',
sessionTitle: currentSession?.title || t('contextSidebar.session.untitled'),
messagesCount: sessionMessages.length,
userMessagesCount: userMessages.length,
assistantMessagesCount: assistantMessages.length,
@@ -386,21 +387,21 @@ export const ContextPanelContent: React.FC = () => {
},
breakdownTotal,
};
}, [currentSessionId, providers, sessionMessages, sessions]);
}, [currentSessionId, providers, sessionMessages, sessions, t]);
if (!currentSessionId) {
return (
<div className="flex h-full items-center justify-center p-6 text-center typography-ui-label text-muted-foreground">
Open a session to inspect context.
<div className="flex h-full items-center justify-center p-6 text-center typography-ui-label text-muted-foreground">
{t('contextSidebar.empty.openSession')}
</div>
);
}
const segments: Array<{ key: string; label: string; value: number; color: string }> = [
{ key: 'user', label: 'User', value: viewModel.breakdown.user, color: 'var(--status-success)' },
{ key: 'assistant', label: 'Assistant', value: viewModel.breakdown.assistant, color: 'var(--primary-base)' },
{ key: 'tool', label: 'Tool Calls', value: viewModel.breakdown.tool, color: 'var(--status-warning)' },
{ key: 'other', label: 'Other', value: viewModel.breakdown.other, color: 'var(--surface-muted-foreground)' },
{ key: 'user', label: t('contextSidebar.breakdown.user'), value: viewModel.breakdown.user, color: 'var(--status-success)' },
{ key: 'assistant', label: t('contextSidebar.breakdown.assistant'), value: viewModel.breakdown.assistant, color: 'var(--primary-base)' },
{ key: 'tool', label: t('contextSidebar.breakdown.toolCalls'), value: viewModel.breakdown.tool, color: 'var(--status-warning)' },
{ key: 'other', label: t('contextSidebar.breakdown.other'), value: viewModel.breakdown.other, color: 'var(--surface-muted-foreground)' },
];
return (
@@ -424,7 +425,7 @@ export const ContextPanelContent: React.FC = () => {
{/* ── Context usage ── */}
<div className="mb-5 rounded-lg bg-[var(--surface-elevated)]/70 px-4 py-3.5">
<div className="flex items-baseline justify-between">
<span className="typography-micro text-muted-foreground">Context</span>
<span className="typography-micro text-muted-foreground">{t('contextSidebar.section.context')}</span>
<span className="typography-micro tabular-nums text-muted-foreground/70">
{formatNumber(viewModel.tokenBreakdown.total)}
{viewModel.contextLimit ? ` / ${formatNumber(viewModel.contextLimit)}` : ''}
@@ -442,17 +443,17 @@ export const ContextPanelContent: React.FC = () => {
)}
</div>
<div className="mt-1.5 typography-micro font-medium tabular-nums text-foreground/80">
{viewModel.usagePercent.toFixed(1)}% used
{t('contextSidebar.context.percentUsed', { percent: viewModel.usagePercent.toFixed(1) })}
</div>
</div>
{/* ── Stat grid ── */}
<div className="mb-5 grid grid-cols-2 gap-2">
{([
{ label: 'Messages', value: formatNumber(viewModel.messagesCount) },
{ label: 'User', value: formatNumber(viewModel.userMessagesCount) },
{ label: 'Assistant', value: formatNumber(viewModel.assistantMessagesCount) },
{ label: 'Cost', value: formatMoney(viewModel.totalAssistantCost) },
{ label: t('contextSidebar.stats.messages'), value: formatNumber(viewModel.messagesCount) },
{ label: t('contextSidebar.stats.user'), value: formatNumber(viewModel.userMessagesCount) },
{ label: t('contextSidebar.stats.assistant'), value: formatNumber(viewModel.assistantMessagesCount) },
{ label: t('contextSidebar.stats.cost'), value: formatMoney(viewModel.totalAssistantCost) },
] as const).map((item) => (
<div key={item.label} className="rounded-lg bg-[var(--surface-elevated)]/70 px-3 py-2.5">
<div className="typography-micro text-muted-foreground/70">{item.label}</div>
@@ -463,14 +464,14 @@ export const ContextPanelContent: React.FC = () => {
{/* ── Last turn tokens ── */}
<div className="mb-5 rounded-lg bg-[var(--surface-elevated)]/70 px-4 py-3.5">
<div className="typography-micro text-muted-foreground">Last Assistant Message</div>
<div className="typography-micro text-muted-foreground">{t('contextSidebar.section.lastAssistantMessage')}</div>
<div className="mt-2.5 grid grid-cols-3 gap-x-4 gap-y-2.5">
{([
{ label: 'Input', value: viewModel.tokenBreakdown.input },
{ label: 'Output', value: viewModel.tokenBreakdown.output },
{ label: 'Reasoning', value: viewModel.tokenBreakdown.reasoning },
{ label: 'Cache Read', value: viewModel.tokenBreakdown.cacheRead },
{ label: 'Cache Write', value: viewModel.tokenBreakdown.cacheWrite },
{ label: t('contextSidebar.tokens.input'), value: viewModel.tokenBreakdown.input },
{ label: t('contextSidebar.tokens.output'), value: viewModel.tokenBreakdown.output },
{ label: t('contextSidebar.tokens.reasoning'), value: viewModel.tokenBreakdown.reasoning },
{ label: t('contextSidebar.tokens.cacheRead'), value: viewModel.tokenBreakdown.cacheRead },
{ label: t('contextSidebar.tokens.cacheWrite'), value: viewModel.tokenBreakdown.cacheWrite },
] as const).map((item) => (
<div key={item.label}>
<div className="typography-micro text-muted-foreground/70">{item.label}</div>
@@ -513,7 +514,7 @@ export const ContextPanelContent: React.FC = () => {
{/* ── Raw messages ── */}
<div>
<div className="typography-micro text-muted-foreground">Raw Messages</div>
<div className="typography-micro text-muted-foreground">{t('contextSidebar.section.rawMessages')}</div>
<div className="mt-2.5 space-y-1">
{[...sessionMessages].reverse().map((message) => {
const role = deriveMessageRole(message.info).role;
@@ -561,8 +562,8 @@ export const ContextPanelContent: React.FC = () => {
event.stopPropagation();
void handleCopyRawMessage(message.info.id, jsonValue);
}}
aria-label={isCopied ? 'Copied' : 'Copy JSON'}
title={isCopied ? 'Copied' : 'Copy'}
aria-label={isCopied ? t('contextSidebar.actions.copied') : t('contextSidebar.actions.copyJson')}
title={isCopied ? t('contextSidebar.actions.copied') : t('contextSidebar.actions.copy')}
>
{isCopied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
</button>
+72 -59
View File
@@ -66,6 +66,7 @@ import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton';
import { isDesktopShell, isVSCodeRuntime, startDesktopWindowDrag } from '@/lib/desktop';
import { desktopHostsGet, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts';
import { resolveSessionDiffStats } from '@/components/session/sidebar/utils';
import { useI18n } from '@/lib/i18n';
import type { Session } from '@opencode-ai/sdk/v2/client';
const DESKTOP_HEADER_ICON_BUTTON_CLASS = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center gap-2 rounded-md typography-ui-label font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:bg-interactive-hover transition-colors';
@@ -132,6 +133,7 @@ const DesktopGitHubControl = React.memo(function DesktopGitHubControl({
isSwitchingGitHubAccount,
handleGitHubAccountSwitch,
}: DesktopGitHubControlProps) {
const { t } = useI18n();
if (!githubAuthStatus?.connected || isMobile) {
return null;
}
@@ -146,13 +148,13 @@ const DesktopGitHubControl = React.memo(function DesktopGitHubControl({
DESKTOP_HEADER_ICON_BUTTON_CLASS,
'h-7 w-7 overflow-hidden rounded-full border border-border/60 bg-muted/80 p-0'
)}
title={githubLogin ? `GitHub: ${githubLogin}` : 'GitHub connected'}
title={githubLogin ? t('header.github.connectedWithLogin', { login: githubLogin }) : t('header.github.connected')}
disabled={isSwitchingGitHubAccount}
>
{githubAvatarUrl ? (
<img
src={githubAvatarUrl}
alt={githubLogin ? `${githubLogin} avatar` : 'GitHub avatar'}
alt={githubLogin ? t('header.github.avatarWithLogin', { login: githubLogin }) : t('header.github.avatar')}
className="h-full w-full object-cover"
loading="lazy"
referrerPolicy="no-referrer"
@@ -164,7 +166,7 @@ const DesktopGitHubControl = React.memo(function DesktopGitHubControl({
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel className="typography-ui-header font-semibold text-foreground">
GitHub Accounts
{t('header.github.accountsTitle')}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{githubAccounts.map((account) => {
@@ -184,7 +186,7 @@ const DesktopGitHubControl = React.memo(function DesktopGitHubControl({
{accountUser?.avatarUrl ? (
<img
src={accountUser.avatarUrl}
alt={accountUser.login ? `${accountUser.login} avatar` : 'GitHub avatar'}
alt={accountUser.login ? t('header.github.avatarWithLogin', { login: accountUser.login }) : t('header.github.avatar')}
className="h-6 w-6 rounded-full border border-border/60 bg-muted object-cover"
loading="lazy"
referrerPolicy="no-referrer"
@@ -216,12 +218,12 @@ const DesktopGitHubControl = React.memo(function DesktopGitHubControl({
return (
<div
className="app-region-no-drag flex h-7 w-7 items-center justify-center overflow-hidden rounded-full border border-border/60 bg-muted/80"
title={githubLogin ? `GitHub: ${githubLogin}` : 'GitHub connected'}
title={githubLogin ? t('header.github.connectedWithLogin', { login: githubLogin }) : t('header.github.connected')}
>
{githubAvatarUrl ? (
<img
src={githubAvatarUrl}
alt={githubLogin ? `${githubLogin} avatar` : 'GitHub avatar'}
alt={githubLogin ? t('header.github.avatarWithLogin', { login: githubLogin }) : t('header.github.avatar')}
className="h-full w-full object-cover"
loading="lazy"
referrerPolicy="no-referrer"
@@ -284,6 +286,7 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
toggleFamilyExpanded,
shortcutLabel,
}: DesktopServicesMenuProps) {
const { t } = useI18n();
return (
<DropdownMenu
open={isDesktopServicesOpen}
@@ -303,8 +306,8 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
<button
type="button"
aria-label={isDesktopApp
? `Open instance, usage and MCP (current: ${currentInstanceLabel})`
: 'Open services, usage and MCP'}
? t('header.services.openWithCurrent', { current: currentInstanceLabel })
: t('header.services.open')}
className={cn(
DESKTOP_HEADER_ICON_BUTTON_CLASS,
isDesktopApp ? 'w-auto max-w-[14rem] justify-start gap-1.5 px-2.5' : 'h-8 w-8'
@@ -319,7 +322,16 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
</TooltipTrigger>
<TooltipContent>
<p>
{isDesktopApp ? `Current instance: ${currentInstanceLabel}` : 'Services'} ({shortcutLabel('toggle_services_menu')}; next tab {shortcutLabel('cycle_services_tab')})
{isDesktopApp
? t('header.services.tooltip.currentInstanceWithShortcuts', {
current: currentInstanceLabel,
toggle: shortcutLabel('toggle_services_menu'),
nextTab: shortcutLabel('cycle_services_tab'),
})
: t('header.services.tooltip.servicesWithShortcuts', {
toggle: shortcutLabel('toggle_services_menu'),
nextTab: shortcutLabel('cycle_services_tab'),
})}
</p>
</TooltipContent>
</Tooltip>
@@ -365,7 +377,7 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
<div className="overflow-x-hidden">
<div className="flex items-center justify-between gap-3 border-b border-[var(--interactive-border)] px-4 py-2.5">
<div className="flex min-w-0 items-baseline gap-2">
<span className="typography-ui-header font-semibold text-foreground">Rate limits</span>
<span className="typography-ui-header font-semibold text-foreground">{t('header.services.rateLimits')}</span>
<span className="truncate typography-micro text-muted-foreground">{formatTime(quotaLastUpdated)}</span>
</div>
<div className="flex items-center gap-1.5">
@@ -389,7 +401,7 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
)}
onClick={handleUsageRefresh}
disabled={isQuotaLoading || isUsageRefreshSpinning}
aria-label="Refresh rate limits"
aria-label={t('header.services.refreshRateLimitsAria')}
>
<RiRefreshLine className={cn('h-4 w-4', isUsageRefreshSpinning && 'animate-spin')} />
</button>
@@ -398,7 +410,7 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
{!hasRateLimits ? (
<div className="px-4 py-5 text-center">
<span className="typography-ui-label text-muted-foreground">No rate limits available.</span>
<span className="typography-ui-label text-muted-foreground">{t('header.services.noRateLimits')}</span>
</div>
) : null}
@@ -414,7 +426,7 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
</div>
{group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? (
<div className="px-4 pb-2">
<span className="typography-ui-label text-muted-foreground">{group.error ?? 'No rate limits reported.'}</span>
<span className="typography-ui-label text-muted-foreground">{group.error ?? t('header.services.noRateLimitsReported')}</span>
</div>
) : (
<div className="space-y-3 px-4 pb-2">
@@ -617,6 +629,7 @@ export const Header: React.FC<HeaderProps> = ({
rightDrawerOpen,
desktopRightSidebarActionsHost = null,
}) => {
const { t } = useI18n();
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
const toggleSidebar = useUIStore((state) => state.toggleSidebar);
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
@@ -904,7 +917,7 @@ export const Header: React.FC<HeaderProps> = ({
if (otherModels.length > 0) {
group.modelFamilies.push({
familyId: null,
familyLabel: 'Other',
familyLabel: t('header.services.modelFamily.other'),
models: otherModels,
});
}
@@ -1438,17 +1451,17 @@ export const Header: React.FC<HeaderProps> = ({
const tabs: TabConfig[] = React.useMemo(() => {
if (isMobile) {
const base: TabConfig[] = [
{ id: 'chat', label: 'Chat', icon: RiChat4Line },
{ id: 'chat', label: t('layout.mainTab.chat'), icon: RiChat4Line },
];
if (showPlanTab) {
base.push({ id: 'plan', label: 'Plan', icon: RiFileTextLine });
base.push({ id: 'plan', label: t('layout.mainTab.plan'), icon: RiFileTextLine });
}
base.push(
{ id: 'diff', label: 'Diff', icon: 'diff' },
{ id: 'files', label: 'Files', icon: RiFolder6Line },
{ id: 'terminal', label: 'Terminal', icon: RiTerminalBoxLine },
{ id: 'diff', label: t('layout.mainTab.diff'), icon: 'diff' },
{ id: 'files', label: t('layout.mainTab.files'), icon: RiFolder6Line },
{ id: 'terminal', label: t('layout.mainTab.terminal'), icon: RiTerminalBoxLine },
);
return base;
@@ -1456,7 +1469,7 @@ export const Header: React.FC<HeaderProps> = ({
// Desktop: no tabs in header
return [];
}, [isMobile, showPlanTab]);
}, [isMobile, showPlanTab, t]);
const shortcutLabel = React.useCallback((actionId: string) => {
return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides));
@@ -1471,14 +1484,14 @@ export const Header: React.FC<HeaderProps> = ({
const servicesTabs = React.useMemo(() => {
const base: Array<{ value: 'instance' | 'usage' | 'mcp'; label: string; icon: RemixiconComponentType }> = [];
if (isDesktopApp) {
base.push({ value: 'instance', label: 'Instance', icon: RiServerLine });
base.push({ value: 'instance', label: t('layout.services.instance'), icon: RiServerLine });
}
base.push(
{ value: 'usage', label: 'Usage', icon: RiTimerLine },
{ value: 'usage', label: t('layout.services.usage'), icon: RiTimerLine },
{ value: 'mcp', label: 'MCP', icon: McpIcon as unknown as RemixiconComponentType }
);
return base;
}, [isDesktopApp]);
}, [isDesktopApp, t]);
const servicesTabItems = React.useMemo(() => {
return servicesTabs.map((tab) => ({
@@ -1490,10 +1503,10 @@ export const Header: React.FC<HeaderProps> = ({
const quotaDisplayTabs = React.useMemo(() => {
return [
{ value: 'usage' as const, label: 'Used' },
{ value: 'remaining' as const, label: 'Remaining' },
{ value: 'usage' as const, label: t('header.services.used') },
{ value: 'remaining' as const, label: t('header.services.remaining') },
];
}, []);
}, [t]);
const quotaDisplayTabItems = React.useMemo(() => {
return quotaDisplayTabs.map((tab) => ({ id: tab.value, label: tab.label }));
@@ -1501,10 +1514,10 @@ export const Header: React.FC<HeaderProps> = ({
const mobileServicesTabItems = React.useMemo<SortableTabsStripItem[]>(() => {
return [
{ id: 'usage', label: 'Usage', icon: <RiTimerLine className="h-3.5 w-3.5" /> },
{ id: 'usage', label: t('layout.services.usage'), icon: <RiTimerLine className="h-3.5 w-3.5" /> },
{ id: 'mcp', label: 'MCP', icon: <RiCommandLine className="h-3.5 w-3.5" /> },
];
}, []);
}, [t]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -1633,17 +1646,17 @@ export const Header: React.FC<HeaderProps> = ({
{showPlanTab && (
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Open plan"
onClick={handleOpenContextPlan}
className={cn(desktopHeaderIconButtonClass, isContextPlanActive && 'bg-[var(--interactive-hover)]')}
>
<button
type="button"
aria-label={t('header.actions.openPlanAria')}
onClick={handleOpenContextPlan}
className={cn(desktopHeaderIconButtonClass, isContextPlanActive && 'bg-[var(--interactive-hover)]')}
>
<RiFileTextLine className="h-[18px] w-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Plan ({shortcutLabel('toggle_context_plan')})</p>
<p>{t('header.actions.planWithShortcut', { shortcut: shortcutLabel('toggle_context_plan') })}</p>
</TooltipContent>
</Tooltip>
)}
@@ -1674,14 +1687,14 @@ export const Header: React.FC<HeaderProps> = ({
shortcutLabel={shortcutLabel}
/>
<HeaderIconActionButton
title={`Terminal panel (${shortcutLabel('toggle_terminal')})`}
ariaLabel="Toggle terminal panel"
title={t('header.actions.terminalPanelWithShortcut', { shortcut: shortcutLabel('toggle_terminal') })}
ariaLabel={t('header.actions.toggleTerminalPanelAria')}
onClick={toggleBottomTerminal}
Icon={RiTerminalBoxLine}
/>
<HeaderIconActionButton
title={`Right sidebar (${shortcutLabel('toggle_right_sidebar')})`}
ariaLabel="Toggle right sidebar"
title={t('header.actions.rightSidebarWithShortcut', { shortcut: shortcutLabel('toggle_right_sidebar') })}
ariaLabel={t('header.actions.toggleRightSidebarAria')}
onClick={toggleRightSidebar}
Icon={RiLayoutRightLine}
/>
@@ -1709,12 +1722,12 @@ export const Header: React.FC<HeaderProps> = ({
)}
style={webWindowControlsOverlayStyle}
role="tablist"
aria-label="Main navigation"
aria-label={t('header.navigation.mainAria')}
>
<HeaderIconActionButton
visible={!isSidebarOpen}
title={`Open sessions (${shortcutLabel('toggle_sidebar')})`}
ariaLabel="Open sessions"
title={t('header.actions.openSessionsWithShortcut', { shortcut: shortcutLabel('toggle_sidebar') })}
ariaLabel={t('header.actions.openSessionsAria')}
onClick={handleOpenSessionSwitcher}
className={`${desktopHeaderIconButtonClass} shrink-0`}
Icon={RiLayoutLeftLine}
@@ -1726,7 +1739,7 @@ export const Header: React.FC<HeaderProps> = ({
<TooltipTrigger asChild>
<button
type="button"
aria-label="New session"
aria-label={t('header.actions.newSessionAria')}
onClick={handleHeaderNewSession}
className={cn(desktopHeaderIconButtonClass, 'mr-6 shrink-0')}
>
@@ -1734,7 +1747,7 @@ export const Header: React.FC<HeaderProps> = ({
</button>
</TooltipTrigger>
<TooltipContent>
<p>New session ({shortcutLabel('new_chat')})</p>
<p>{t('header.actions.newSessionWithShortcut', { shortcut: shortcutLabel('new_chat') })}</p>
</TooltipContent>
</Tooltip>
) : null}
@@ -1827,7 +1840,7 @@ export const Header: React.FC<HeaderProps> = ({
mobileHeaderIconButtonClass,
leftDrawerOpen && 'bg-interactive-selection text-interactive-selection-foreground'
)}
aria-label={leftDrawerOpen ? 'Close sessions' : 'Open sessions'}
aria-label={leftDrawerOpen ? t('header.actions.closeSessionsAria') : t('header.actions.openSessionsAria')}
>
<RiLayoutLeftLine className="h-5 w-5" />
</button>
@@ -1836,7 +1849,7 @@ export const Header: React.FC<HeaderProps> = ({
type="button"
onClick={() => setSessionSwitcherOpen(false)}
className="app-region-no-drag h-9 w-9 p-2 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-md active:bg-interactive-active"
aria-label="Back"
aria-label={t('header.actions.backAria')}
>
<RiArrowLeftSLine className="h-5 w-5" />
</button>
@@ -1845,14 +1858,14 @@ export const Header: React.FC<HeaderProps> = ({
type="button"
onClick={handleOpenSessionSwitcher}
className="app-region-no-drag h-9 w-9 p-2 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-md active:bg-interactive-active"
aria-label="Open sessions"
aria-label={t('header.actions.openSessionsAria')}
>
<RiPlayListAddLine className="h-5 w-5" />
</button>
)}
{isSessionSwitcherOpen && (
<span className="typography-ui-label font-semibold text-foreground">Sessions</span>
<span className="typography-ui-label font-semibold text-foreground">{t('header.sessions.title')}</span>
)}
</div>
@@ -1865,7 +1878,7 @@ export const Header: React.FC<HeaderProps> = ({
<div
className="flex items-center gap-0.5 rounded-lg bg-[var(--surface-muted)]/50 p-0.5"
role="tablist"
aria-label="Main navigation"
aria-label={t('header.navigation.mainAria')}
>
{tabs.map((tab) => {
const isActive = activeMainTab === tab.id;
@@ -1904,7 +1917,7 @@ export const Header: React.FC<HeaderProps> = ({
{tab.showDot && (
<span
className="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-primary"
aria-label="Changes available"
aria-label={t('header.changes.availableAria')}
/>
)}
</button>
@@ -1946,7 +1959,7 @@ export const Header: React.FC<HeaderProps> = ({
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="View services"
aria-label={t('header.services.viewAria')}
className={mobileHeaderIconButtonClass}
>
<RiStackLine className="h-5 w-5" />
@@ -1954,7 +1967,7 @@ export const Header: React.FC<HeaderProps> = ({
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Services</p>
<p>{t('header.services.title')}</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent
@@ -1987,7 +2000,7 @@ export const Header: React.FC<HeaderProps> = ({
type="button"
onClick={() => setIsMobileRateLimitsOpen(false)}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover"
aria-label="Close services"
aria-label={t('header.services.closeAria')}
>
<RiCloseLine className="h-5 w-5" />
</button>
@@ -2004,7 +2017,7 @@ export const Header: React.FC<HeaderProps> = ({
<div className="border-b border-[var(--interactive-border)]">
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="flex flex-col min-w-0 gap-0.5">
<span className="typography-ui-header font-semibold text-foreground">Rate limits</span>
<span className="typography-ui-header font-semibold text-foreground">{t('header.services.rateLimits')}</span>
<span className="truncate typography-micro text-muted-foreground">
{formatTime(quotaLastUpdated)}
</span>
@@ -2021,7 +2034,7 @@ export const Header: React.FC<HeaderProps> = ({
: 'text-muted-foreground hover:text-foreground'
)}
>
Used
{t('header.services.used')}
</button>
<span className="text-muted-foreground typography-ui-label px-0.5">·</span>
<button
@@ -2034,7 +2047,7 @@ export const Header: React.FC<HeaderProps> = ({
: 'text-muted-foreground hover:text-foreground'
)}
>
Remaining
{t('header.services.remaining')}
</button>
</div>
<button
@@ -2046,7 +2059,7 @@ export const Header: React.FC<HeaderProps> = ({
)}
onClick={handleUsageRefresh}
disabled={isQuotaLoading || isUsageRefreshSpinning}
aria-label="Refresh rate limits"
aria-label={t('header.services.refreshRateLimitsAria')}
>
<RiRefreshLine className={cn('h-4 w-4', isUsageRefreshSpinning && 'animate-spin')} />
</button>
@@ -2056,7 +2069,7 @@ export const Header: React.FC<HeaderProps> = ({
{!hasRateLimits && (
<div className="px-4 py-6 text-center">
<span className="typography-ui-label text-muted-foreground">No rate limits available.</span>
<span className="typography-ui-label text-muted-foreground">{t('header.services.noRateLimits')}</span>
</div>
)}
@@ -2077,7 +2090,7 @@ export const Header: React.FC<HeaderProps> = ({
{group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? (
<div className="px-4 pb-2">
<span className="typography-ui-label text-muted-foreground">
{group.error ?? 'No rate limits reported.'}
{group.error ?? t('header.services.noRateLimitsReported')}
</span>
</div>
) : (
@@ -20,6 +20,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { useDeviceInfo } from '@/lib/device';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { isDesktopShell } from '@/lib/desktop';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
@@ -63,6 +64,7 @@ const normalizeDirectoryKey = (value: string): string => {
};
export const MainLayout: React.FC = () => {
const { t } = useI18n();
const RIGHT_SIDEBAR_AUTO_CLOSE_WIDTH = 1140;
const RIGHT_SIDEBAR_AUTO_OPEN_WIDTH = 1220;
const BOTTOM_TERMINAL_AUTO_CLOSE_HEIGHT = 640;
@@ -696,7 +698,7 @@ export const MainLayout: React.FC = () => {
setMobileLeftDrawerOpen(false);
setRightSidebarOpen(false);
}}
aria-label="Close drawer"
aria-label={t('mainLayout.mobile.closeDrawerAria')}
/>
{/* Left drawer (Session) */}
@@ -22,6 +22,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { useTerminalStore } from '@/stores/useTerminalStore';
import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
import { openExternalUrl } from '@/lib/url';
import { useI18n } from '@/lib/i18n';
import {
getProjectActionsState,
type OpenChamberProjectAction,
@@ -154,10 +155,10 @@ const extractBestUrl = (value: string): string | null => {
return normalized[0] ?? null;
};
const formatActionButtonLabel = (value: string): string => {
const formatActionButtonLabel = (value: string, fallbackLabel: string): string => {
const trimmed = value.trim();
if (!trimmed) {
return 'Action';
return fallbackLabel;
}
const words = trimmed.split(/\s+/).filter(Boolean);
@@ -181,6 +182,7 @@ export const ProjectActionsButton = ({
compact = false,
allowMobile = false,
}: ProjectActionsButtonProps) => {
const { t } = useI18n();
const { terminal, runtime } = useRuntimeAPIs();
const { isMobile } = useDeviceInfo();
const isDesktopShellApp = React.useMemo(() => isDesktopShell(), []);
@@ -357,7 +359,7 @@ export const ProjectActionsButton = ({
if (maybeUrl) {
watch.openedUrl = true;
void openExternal(maybeUrl);
toast.success('Opened URL from action output');
toast.success(t('projectActions.toast.openedUrlFromOutput'));
}
urlWatchByRunKeyRef.current[runKey] = watch;
}
@@ -383,7 +385,7 @@ export const ProjectActionsButton = ({
const getOrCreateActionTab = React.useCallback(async (action: OpenChamberProjectAction) => {
if (!normalizedDirectory) {
throw new Error('No active directory');
throw new Error(t('projectActions.error.noActiveDirectory'));
}
const key = toProjectActionRunKey(normalizedDirectory, action.id);
@@ -430,7 +432,7 @@ export const ProjectActionsButton = ({
}
if (!normalizedDirectory) {
toast.error('No active directory for action');
toast.error(t('projectActions.error.noActiveDirectoryForAction'));
return;
}
@@ -458,7 +460,7 @@ export const ProjectActionsButton = ({
}
if (!activeSessionId) {
throw new Error('Failed to create terminal session');
throw new Error(t('projectActions.error.failedToCreateTerminalSession'));
}
if (createdSession) {
@@ -488,14 +490,14 @@ export const ProjectActionsButton = ({
if (desktopForwardUrl) {
void openExternal(desktopForwardUrl);
toast.success('Opened forwarded URL');
toast.success(t('projectActions.toast.openedForwardedUrl'));
} else if (manualOpenUrl) {
void openExternal(manualOpenUrl);
toast.success('Opened action URL');
toast.success(t('projectActions.toast.openedActionUrl'));
} else if (hasCustomOpenUrl) {
toast.error('Invalid custom URL format');
toast.error(t('projectActions.error.invalidCustomUrlFormat'));
} else if (hasDesktopForwardSelection) {
toast.error('Selected desktop SSH forward is unavailable');
toast.error(t('projectActions.error.selectedDesktopSshForwardUnavailable'));
}
urlWatchByRunKeyRef.current[key] = {
@@ -513,7 +515,7 @@ export const ProjectActionsButton = ({
return next;
});
delete urlWatchByRunKeyRef.current[runKey];
toast.error(error instanceof Error ? error.message : 'Failed to run action');
toast.error(error instanceof Error ? error.message : t('projectActions.error.failedToRunAction'));
}
}, [
desktopSshInstances,
@@ -645,7 +647,7 @@ export const ProjectActionsButton = ({
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
className
)}
aria-label="Add action"
aria-label={t('projectActions.actions.addActionAria')}
onClick={openProjectActionsSettings}
>
<RiAddLine className="h-5 w-5" />
@@ -666,7 +668,7 @@ export const ProjectActionsButton = ({
onClick={openProjectActionsSettings}
>
<RiAddLine className="h-4 w-4 text-muted-foreground" />
<span className="header-open-label whitespace-nowrap">Add action</span>
<span className="header-open-label whitespace-nowrap">{t('projectActions.actions.addAction')}</span>
</button>
);
}
@@ -678,7 +680,10 @@ export const ProjectActionsButton = ({
const selectedIconKey = (resolvedSelected.icon || 'play') as keyof typeof PROJECT_ACTION_ICON_MAP;
const SelectedIcon = PROJECT_ACTION_ICON_MAP[selectedIconKey] || RiPlayLine;
const selectedButtonLabel = formatActionButtonLabel(resolvedSelected.name);
const selectedButtonLabel = formatActionButtonLabel(
resolvedSelected.name,
t('projectActions.label.fallbackAction'),
);
const selectedRunKey = toProjectActionRunKey(normalizedDirectory, resolvedSelected.id);
const selectedRunning = runningByKey[selectedRunKey];
const isStoppingSelected = selectedRunning?.status === 'stopping';
@@ -697,7 +702,9 @@ export const ProjectActionsButton = ({
'disabled:cursor-not-allowed',
className
)}
aria-label={selectedRunning ? `Stop ${resolvedSelected.name}` : `Run ${resolvedSelected.name}`}
aria-label={selectedRunning
? t('projectActions.actions.stopNamedAria', { name: resolvedSelected.name })
: t('projectActions.actions.runNamedAria', { name: resolvedSelected.name })}
>
{isStoppingSelected
? <RiLoader4Line className="h-5 w-5 animate-spin text-[var(--status-warning)]" />
@@ -709,7 +716,7 @@ export const ProjectActionsButton = ({
<DropdownMenuContent align="end" className="w-52 max-h-[70vh] overflow-y-auto">
<DropdownMenuItem className="flex items-center gap-2" onClick={openProjectActionsSettings}>
<RiAddLine className="h-4 w-4" />
<span className="typography-ui-label text-foreground">Add new action</span>
<span className="typography-ui-label text-foreground">{t('projectActions.actions.addNewAction')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
{actions.map((entry) => {
@@ -762,7 +769,9 @@ export const ProjectActionsButton = ({
compact ? 'w-9 justify-center px-0' : 'gap-2 px-3',
'transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed'
)}
aria-label={selectedRunning ? `Stop ${resolvedSelected.name}` : `Run ${resolvedSelected.name}`}
aria-label={selectedRunning
? t('projectActions.actions.stopNamedAria', { name: resolvedSelected.name })
: t('projectActions.actions.runNamedAria', { name: resolvedSelected.name })}
>
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center">
{isStoppingSelected
@@ -783,7 +792,7 @@ export const ProjectActionsButton = ({
'border-l border-[var(--interactive-border)] text-muted-foreground',
'hover:bg-interactive-hover hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
)}
aria-label="Choose project action"
aria-label={t('projectActions.actions.chooseActionAria')}
>
<RiArrowDownSLine className="h-4 w-4" />
</button>
@@ -791,7 +800,7 @@ export const ProjectActionsButton = ({
<DropdownMenuContent align="center" className="w-52 max-h-[70vh] overflow-y-auto" style={{ translate: '-30px 0' }}>
<DropdownMenuItem className="flex items-center gap-2" onClick={openProjectActionsSettings}>
<RiAddLine className="h-4 w-4" />
<span className="typography-ui-label text-foreground">Add new action</span>
<span className="typography-ui-label text-foreground">{t('projectActions.actions.addNewAction')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
{actions.map((entry) => {
@@ -13,6 +13,7 @@ import { cn } from '@/lib/utils';
import { PROJECT_ICONS, PROJECT_COLORS, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useI18n } from '@/lib/i18n';
interface ProjectEditDialogProps {
open: boolean;
@@ -50,6 +51,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
initialIconBackground = null,
onSave,
}) => {
const { t } = useI18n();
const uploadProjectIcon = useProjectsStore((state) => state.uploadProjectIcon);
const removeProjectIcon = useProjectsStore((state) => state.removeProjectIcon);
const discoverProjectIcon = useProjectsStore((state) => state.discoverProjectIcon);
@@ -105,10 +107,10 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
const uploadResult = await uploadProjectIcon(projectId, pendingUploadIconFile);
setIsUploadingIcon(false);
if (!uploadResult.ok) {
toast.error(uploadResult.error || 'Failed to upload project icon');
toast.error(uploadResult.error || t('projectEditDialog.toast.failedToUploadIcon'));
return;
}
toast.success('Project icon updated');
toast.success(t('projectEditDialog.toast.iconUpdated'));
clearPendingUploadIcon();
setPendingRemoveImageIcon(false);
}
@@ -120,10 +122,10 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
const result = await removeProjectIcon(projectId);
setIsRemovingCustomIcon(false);
if (!result.ok) {
toast.error(result.error || 'Failed to remove project icon');
toast.error(result.error || t('projectEditDialog.toast.failedToRemoveIcon'));
return;
}
toast.success('Project icon removed');
toast.success(t('projectEditDialog.toast.iconRemoved'));
setPendingRemoveImageIcon(false);
setIconBackground(null);
}
@@ -213,37 +215,37 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
void discoverProjectIcon(projectId)
.then((result) => {
if (!result.ok) {
toast.error(result.error || 'Failed to discover project icon');
toast.error(result.error || t('projectEditDialog.toast.failedToDiscoverIcon'));
return;
}
if (result.skipped) {
toast.success('Custom icon already set for this project');
toast.success(t('projectEditDialog.toast.customIconAlreadySet'));
return;
}
toast.success('Project icon discovered');
toast.success(t('projectEditDialog.toast.iconDiscovered'));
})
.finally(() => {
setIsDiscoveringIcon(false);
});
}, [clearPendingUploadIcon, discoverProjectIcon, isDiscoveringIcon, projectId]);
}, [clearPendingUploadIcon, discoverProjectIcon, isDiscoveringIcon, projectId, t]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader className="min-w-0">
<DialogTitle>Edit project</DialogTitle>
<DialogTitle>{t('projectEditDialog.title')}</DialogTitle>
</DialogHeader>
<div className="min-w-0 space-y-5 py-1">
{/* Name */}
<div className="min-w-0 space-y-1.5">
<label className="typography-ui-label font-medium text-foreground">
Name
{t('projectEditDialog.field.name')}
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Project name"
placeholder={t('projectEditDialog.field.namePlaceholder')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
@@ -260,7 +262,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
{/* Color */}
<div className="min-w-0 space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Color
{t('projectEditDialog.field.color')}
</label>
<div className="flex gap-2 flex-wrap">
{/* No color option */}
@@ -273,7 +275,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
? 'border-foreground scale-110'
: 'border-border hover:border-border/80'
)}
title="None"
title={t('projectEditDialog.option.none')}
>
<span className="w-4 h-0.5 bg-muted-foreground/40 rotate-45 rounded-full" />
</button>
@@ -298,7 +300,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
{/* Icon */}
<div className="min-w-0 space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Icon
{t('projectEditDialog.field.icon')}
</label>
<input
ref={fileInputRef}
@@ -322,7 +324,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
? 'border-foreground scale-110 bg-[var(--surface-elevated)]'
: 'border-border hover:border-border/80'
)}
title="None"
title={t('projectEditDialog.option.none')}
>
<span className="w-4 h-0.5 bg-muted-foreground/40 rotate-45 rounded-full" />
</button>
@@ -351,7 +353,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
</div>
{effectiveHasImageIcon && iconPreviewUrl && (
<div className="flex items-center gap-2 pt-1">
<span className="typography-meta text-muted-foreground">Preview</span>
<span className="typography-meta text-muted-foreground">{t('projectEditDialog.field.preview')}</span>
<span className="inline-flex h-8 w-8 items-center justify-center rounded-lg 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]"
@@ -372,21 +374,21 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
{!hasCustomIcon && (
<>
<Button size="sm" variant="outline" onClick={() => fileInputRef.current?.click()} disabled={isUploadingIcon}>
{isUploadingIcon ? 'Uploading...' : 'Upload Icon'}
{isUploadingIcon ? t('projectEditDialog.actions.uploading') : t('projectEditDialog.actions.uploadIcon')}
</Button>
<Button size="sm" variant="outline" onClick={() => void handleDiscoverIcon()} disabled={isDiscoveringIcon}>
{isDiscoveringIcon ? 'Discovering...' : 'Discover Favicon'}
{isDiscoveringIcon ? t('projectEditDialog.actions.discovering') : t('projectEditDialog.actions.discoverFavicon')}
</Button>
</>
)}
{hasRemovableImageIcon && (
<Button size="sm" variant="outline" onClick={() => void handleRemoveImageIcon()} disabled={isRemovingCustomIcon}>
{isRemovingCustomIcon ? 'Removing...' : 'Remove Project Icon'}
{isRemovingCustomIcon ? t('projectEditDialog.actions.removing') : t('projectEditDialog.actions.removeProjectIcon')}
</Button>
)}
{pendingRemoveImageIcon && (
<Button size="sm" variant="outline" onClick={() => setPendingRemoveImageIcon(false)} disabled={isRemovingCustomIcon}>
Undo Remove
{t('projectEditDialog.actions.undoRemove')}
</Button>
)}
</div>
@@ -395,7 +397,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
{effectiveHasImageIcon && (
<div className="min-w-0 space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Icon Background
{t('projectEditDialog.field.iconBackground')}
</label>
<div className="flex flex-wrap items-center gap-2">
<input
@@ -403,7 +405,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
value={iconBackground ?? '#000000'}
onChange={(event) => setIconBackground(event.target.value)}
className="h-8 w-10 cursor-pointer rounded border border-border bg-transparent p-1"
aria-label="Project icon background color"
aria-label={t('projectEditDialog.field.iconBackgroundAria')}
/>
<Input
value={iconBackground ?? ''}
@@ -412,7 +414,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
className="h-8 w-[8.5rem]"
/>
<Button size="sm" variant="outline" onClick={() => setIconBackground(null)}>
Clear
{t('projectEditDialog.actions.clear')}
</Button>
</div>
</div>
@@ -421,10 +423,10 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
{t('projectEditDialog.actions.cancel')}
</Button>
<Button onClick={handleSave} disabled={!name.trim() || isUploadingIcon || isRemovingCustomIcon}>
Save
{t('projectEditDialog.actions.save')}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,6 +1,7 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n';
import { isDesktopShell, isVSCodeRuntime, startDesktopWindowDrag } from '@/lib/desktop';
export const RIGHT_SIDEBAR_CONTENT_WIDTH = 420;
@@ -15,6 +16,7 @@ interface RightSidebarProps {
}
export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children, className, onTopActionsHostChange }) => {
const { t } = useI18n();
const rightSidebarWidth = useUIStore((state) => state.rightSidebarWidth);
const setRightSidebarWidth = useUIStore((state) => state.setRightSidebarWidth);
const isDesktopApp = React.useMemo(() => isDesktopShell(), []);
@@ -182,7 +184,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children, cl
onPointerCancel={handlePointerEnd}
role="separator"
aria-orientation="vertical"
aria-label="Resize right panel"
aria-label={t('sidebar.resize.rightPanelAria')}
/>
)}
<div
@@ -11,6 +11,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { formatDirectoryName } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { SidebarFilesTree } from './SidebarFilesTree';
type RightTab = 'git' | 'files' | 'context';
@@ -90,6 +91,7 @@ const ContextSidebarPanel: React.FC = () => {
};
export const RightSidebarTabs: React.FC = () => {
const { t } = useI18n();
const rightSidebarTab = useUIStore((state) => state.rightSidebarTab);
const setRightSidebarTab = useUIStore((state) => state.setRightSidebarTab);
const isRightSidebarOpen = useUIStore((state) => state.isRightSidebarOpen);
@@ -100,20 +102,20 @@ export const RightSidebarTabs: React.FC = () => {
const tabItems = React.useMemo(() => [
{
id: 'git',
label: 'Git',
label: t('layout.rightSidebar.git'),
icon: <RiGitBranchLine className="h-3.5 w-3.5" />,
},
{
id: 'files',
label: 'Files',
label: t('layout.rightSidebar.files'),
icon: <RiFolder3Line className="h-3.5 w-3.5" />,
},
{
id: 'context',
label: 'Context',
label: t('layout.rightSidebar.context'),
icon: <RiBookletLine className="h-3.5 w-3.5" />,
},
], []);
], [t]);
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-sidebar">
@@ -1,6 +1,7 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { ErrorBoundary } from '../ui/ErrorBoundary';
import { useI18n } from '@/lib/i18n';
import { useUIStore } from '@/stores/useUIStore';
export const SIDEBAR_CONTENT_WIDTH = 280;
@@ -15,6 +16,7 @@ interface SidebarProps {
}
export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children, className }) => {
const { t } = useI18n();
const sidebarWidth = useUIStore((state) => state.sidebarWidth);
const setSidebarWidth = useUIStore((state) => state.setSidebarWidth);
const [isResizing, setIsResizing] = React.useState(false);
@@ -143,7 +145,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children, cl
onPointerCancel={handlePointerEnd}
role="separator"
aria-orientation="vertical"
aria-label="Resize left panel"
aria-label={t('sidebar.resize.leftPanelAria')}
/>
)}
<div
@@ -1,62 +0,0 @@
import React from 'react';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessions } from '@/sync/sync-context';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { cn } from '@/lib/utils';
interface SidebarContextSummaryProps {
className?: string;
}
const formatSessionTitle = (title?: string | null) => {
if (!title) {
return 'Untitled Session';
}
const trimmed = title.trim();
return trimmed.length > 0 ? trimmed : 'Untitled Session';
};
const formatDirectoryPath = (path?: string) => {
if (!path || path.length === 0) {
return '/';
}
return path;
};
export const SidebarContextSummary: React.FC<SidebarContextSummaryProps> = ({ className }) => {
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessions = useSessions();
const { currentDirectory } = useDirectoryStore();
const activeSessionTitle = React.useMemo(() => {
if (!currentSessionId) {
return 'No active session';
}
const session = sessions.find((item) => item.id === currentSessionId);
return session ? formatSessionTitle(session.title) : 'No active session';
}, [currentSessionId, sessions]);
const directoryFull = React.useMemo(() => {
return formatDirectoryPath(currentDirectory);
}, [currentDirectory]);
const directoryDisplay = React.useMemo(() => {
if (!directoryFull || directoryFull === '/') {
return directoryFull;
}
const segments = directoryFull.split('/').filter(Boolean);
return segments.length ? segments[segments.length - 1] : directoryFull;
}, [directoryFull]);
return (
<div className={cn('hidden min-h-[48px] flex-col justify-center gap-0.5 border-b bg-sidebar px-3 py-2 md:flex md:pb-2', className)}>
<span className="typography-meta text-muted-foreground">Session</span>
<span className="typography-ui-label font-semibold text-foreground truncate" title={activeSessionTitle}>
{activeSessionTitle}
</span>
<span className="typography-meta text-muted-foreground truncate" title={directoryFull}>
{directoryDisplay}
</span>
</div>
);
};
@@ -45,10 +45,11 @@ import { useGitStatus } from '@/stores/useGitStore';
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { copyTextToClipboard } from '@/lib/clipboard';
import { cn, getRevealLabel } from '@/lib/utils';
import { cn, getRevealLabelKey } from '@/lib/utils';
import { opencodeClient } from '@/lib/opencode/client';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
import { useI18n } from '@/lib/i18n';
type FileNode = {
name: string;
@@ -171,6 +172,7 @@ const FileRow: React.FC<FileRowProps> = ({
onRevealPath,
onOpenDialog,
}) => {
const { t } = useI18n();
const isDir = node.type === 'directory';
const { canRename, canCreateFile, canCreateFolder, canDelete, canReveal } = permissions;
@@ -256,32 +258,32 @@ const FileRow: React.FC<FileRowProps> = ({
<DropdownMenuContent align="end" side="bottom" onCloseAutoFocus={() => setContextMenuPath(null)}>
{canRename && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onOpenDialog('rename', node); }}>
<RiEditLine className="mr-2 h-4 w-4" /> Rename
<RiEditLine className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.rename')}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
void copyTextToClipboard(node.path).then((result) => {
if (result.ok) {
toast.success('Path copied');
toast.success(t('sidebarFilesTree.toast.pathCopied'));
return;
}
toast.error('Copy failed');
toast.error(t('sidebarFilesTree.toast.copyFailed'));
});
}}>
<RiFileCopyLine className="mr-2 h-4 w-4" /> Copy Path
<RiFileCopyLine className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.copyPath')}
</DropdownMenuItem>
{!isDir && downloadFile && (
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
void downloadFile(node.path);
}}>
<RiDownloadLine className="mr-2 h-4 w-4" /> Save
<RiDownloadLine className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.save')}
</DropdownMenuItem>
)}
{canReveal && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onRevealPath(node.path); }}>
<RiFolderReceivedLine className="mr-2 h-4 w-4" /> {getRevealLabel()}
<RiFolderReceivedLine className="mr-2 h-4 w-4" /> {t(getRevealLabelKey())}
</DropdownMenuItem>
)}
{isDir && (canCreateFile || canCreateFolder) && (
@@ -289,12 +291,12 @@ const FileRow: React.FC<FileRowProps> = ({
<DropdownMenuSeparator />
{canCreateFile && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onOpenDialog('createFile', node); }}>
<RiFileAddLine className="mr-2 h-4 w-4" /> New File
<RiFileAddLine className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.newFile')}
</DropdownMenuItem>
)}
{canCreateFolder && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onOpenDialog('createFolder', node); }}>
<RiFolderAddLine className="mr-2 h-4 w-4" /> New Folder
<RiFolderAddLine className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.newFolder')}
</DropdownMenuItem>
)}
</>
@@ -306,7 +308,7 @@ const FileRow: React.FC<FileRowProps> = ({
onClick={(e) => { e.stopPropagation(); onOpenDialog('delete', node); }}
className="text-destructive focus:text-destructive"
>
<RiDeleteBinLine className="mr-2 h-4 w-4" /> Delete
<RiDeleteBinLine className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.delete')}
</DropdownMenuItem>
</>
)}
@@ -321,6 +323,7 @@ const FileRow: React.FC<FileRowProps> = ({
// --- Main component ---
export const SidebarFilesTree: React.FC = () => {
const { t } = useI18n();
const { files, runtime } = useRuntimeAPIs();
const currentDirectory = useEffectiveDirectory() ?? '';
const root = normalizePath(currentDirectory.trim());
@@ -374,9 +377,9 @@ export const SidebarFilesTree: React.FC = () => {
const handleRevealPath = React.useCallback((targetPath: string) => {
if (!files.revealPath) return;
void files.revealPath(targetPath).catch(() => {
toast.error('Failed to reveal path');
toast.error(t('sidebarFilesTree.toast.revealFailed'));
});
}, [files]);
}, [files, t]);
const handleOpenDialog = React.useCallback((type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => {
setActiveDialog(type);
@@ -638,12 +641,12 @@ export const SidebarFilesTree: React.FC = () => {
if (activeDialog === 'createFile') {
if (!dialogInputValue.trim()) {
toast.error('Filename is required');
toast.error(t('sidebarFilesTree.toast.filenameRequired'));
done();
return;
}
if (!files.writeFile) {
toast.error('Write not supported');
toast.error(t('sidebarFilesTree.toast.writeNotSupported'));
done();
return;
}
@@ -655,19 +658,19 @@ export const SidebarFilesTree: React.FC = () => {
await files.writeFile(newPath, '')
.then(async (result) => {
if (result.success) {
toast.success('File created');
toast.success(t('sidebarFilesTree.toast.fileCreated'));
await refreshDirectory(parentPath);
}
closeDialog();
})
.catch(() => toast.error('Operation failed'))
.catch(() => toast.error(t('sidebarFilesTree.toast.operationFailed')))
.finally(done);
return;
}
if (activeDialog === 'createFolder') {
if (!dialogInputValue.trim()) {
toast.error('Folder name is required');
toast.error(t('sidebarFilesTree.toast.folderNameRequired'));
done();
return;
}
@@ -679,24 +682,24 @@ export const SidebarFilesTree: React.FC = () => {
await files.createDirectory(newPath)
.then(async (result) => {
if (result.success) {
toast.success('Folder created');
toast.success(t('sidebarFilesTree.toast.folderCreated'));
await refreshDirectory(parentPath);
}
closeDialog();
})
.catch(() => toast.error('Operation failed'))
.catch(() => toast.error(t('sidebarFilesTree.toast.operationFailed')))
.finally(done);
return;
}
if (activeDialog === 'rename') {
if (!dialogInputValue.trim()) {
toast.error('Name is required');
toast.error(t('sidebarFilesTree.toast.nameRequired'));
done();
return;
}
if (!files.rename) {
toast.error('Rename not supported');
toast.error(t('sidebarFilesTree.toast.renameNotSupported'));
done();
return;
}
@@ -709,7 +712,7 @@ export const SidebarFilesTree: React.FC = () => {
await files.rename(oldPath, newPath)
.then(async (result) => {
if (result.success) {
toast.success('Renamed successfully');
toast.success(t('sidebarFilesTree.toast.renamedSuccessfully'));
await refreshDirectory(parentDir);
if (root) {
removeOpenPathsByPrefix(root, oldPath);
@@ -720,14 +723,14 @@ export const SidebarFilesTree: React.FC = () => {
}
closeDialog();
})
.catch(() => toast.error('Operation failed'))
.catch(() => toast.error(t('sidebarFilesTree.toast.operationFailed')))
.finally(done);
return;
}
if (activeDialog === 'delete') {
if (!files.delete) {
toast.error('Delete not supported');
toast.error(t('sidebarFilesTree.toast.deleteNotSupported'));
done();
return;
}
@@ -737,7 +740,7 @@ export const SidebarFilesTree: React.FC = () => {
await files.delete(deletedPath)
.then(async (result) => {
if (result.success) {
toast.success('Deleted successfully');
toast.success(t('sidebarFilesTree.toast.deletedSuccessfully'));
await refreshDirectory(parentDir);
if (root) {
removeOpenPathsByPrefix(root, deletedPath);
@@ -748,13 +751,13 @@ export const SidebarFilesTree: React.FC = () => {
}
closeDialog();
})
.catch(() => toast.error('Operation failed'))
.catch(() => toast.error(t('sidebarFilesTree.toast.operationFailed')))
.finally(done);
return;
}
done();
}, [activeDialog, dialogData, dialogInputValue, files, refreshDirectory, removeOpenPathsByPrefix, root, selectedPath, setSelectedPath]);
}, [activeDialog, dialogData, dialogInputValue, files, refreshDirectory, removeOpenPathsByPrefix, root, selectedPath, setSelectedPath, t]);
// --- Tree rendering (matching FilesView with indent guides) ---
@@ -814,13 +817,13 @@ export const SidebarFilesTree: React.FC = () => {
ref={searchInputRef}
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search files..."
placeholder={t('sidebarFilesTree.search.placeholder')}
className="h-8 pl-8 pr-8 typography-meta"
/>
{searchQuery.trim().length > 0 ? (
<button
type="button"
aria-label="Clear search"
aria-label={t('sidebarFilesTree.search.clearAria')}
className="absolute right-2 top-2 inline-flex h-4 w-4 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() => {
setSearchQuery('');
@@ -837,7 +840,7 @@ export const SidebarFilesTree: React.FC = () => {
size="sm"
onClick={() => handleOpenDialog('createFile', { path: currentDirectory, type: 'directory' })}
className="h-8 w-8 p-0 flex-shrink-0"
title="New File"
title={t('sidebarFilesTree.actions.newFileTitle')}
>
<RiFileAddLine className="h-4 w-4" />
</Button>
@@ -848,12 +851,12 @@ export const SidebarFilesTree: React.FC = () => {
size="sm"
onClick={() => handleOpenDialog('createFolder', { path: currentDirectory, type: 'directory' })}
className="h-8 w-8 p-0 flex-shrink-0"
title="New Folder"
title={t('sidebarFilesTree.actions.newFolderTitle')}
>
<RiFolderAddLine className="h-4 w-4" />
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => void refreshRoot()} className="h-8 w-8 p-0 flex-shrink-0" title="Refresh">
<Button variant="ghost" size="sm" onClick={() => void refreshRoot()} className="h-8 w-8 p-0 flex-shrink-0" title={t('sidebarFilesTree.actions.refreshTitle')}>
<RiRefreshLine className="h-4 w-4" />
</Button>
</div>
@@ -863,7 +866,7 @@ export const SidebarFilesTree: React.FC = () => {
{searching ? (
<li className="flex items-center gap-1.5 px-2 py-1 typography-meta text-muted-foreground">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Searching...
{t('sidebarFilesTree.state.searching')}
</li>
) : searchResults.length > 0 ? (
searchResults.map((node) => {
@@ -900,7 +903,7 @@ export const SidebarFilesTree: React.FC = () => {
) : hasTree && root ? (
renderTree(root, 0)
) : (
<li className="px-2 py-1 typography-meta text-muted-foreground">Loading...</li>
<li className="px-2 py-1 typography-meta text-muted-foreground">{t('sidebarFilesTree.state.loading')}</li>
)}
</ul>
</ScrollableOverlay>
@@ -910,16 +913,16 @@ export const SidebarFilesTree: React.FC = () => {
<DialogContent>
<DialogHeader>
<DialogTitle>
{activeDialog === 'createFile' && 'Create File'}
{activeDialog === 'createFolder' && 'Create Folder'}
{activeDialog === 'rename' && 'Rename'}
{activeDialog === 'delete' && 'Delete'}
{activeDialog === 'createFile' && t('sidebarFilesTree.dialog.createFile.title')}
{activeDialog === 'createFolder' && t('sidebarFilesTree.dialog.createFolder.title')}
{activeDialog === 'rename' && t('sidebarFilesTree.dialog.rename.title')}
{activeDialog === 'delete' && t('sidebarFilesTree.dialog.delete.title')}
</DialogTitle>
<DialogDescription>
{activeDialog === 'createFile' && `Create a new file in ${dialogData?.path ?? 'root'}`}
{activeDialog === 'createFolder' && `Create a new folder in ${dialogData?.path ?? 'root'}`}
{activeDialog === 'rename' && `Rename ${dialogData?.name}`}
{activeDialog === 'delete' && `Are you sure you want to delete ${dialogData?.name}? This action cannot be undone.`}
{activeDialog === 'createFile' && t('sidebarFilesTree.dialog.createFile.description', { path: dialogData?.path ?? t('sidebarFilesTree.dialog.rootFallback') })}
{activeDialog === 'createFolder' && t('sidebarFilesTree.dialog.createFolder.description', { path: dialogData?.path ?? t('sidebarFilesTree.dialog.rootFallback') })}
{activeDialog === 'rename' && t('sidebarFilesTree.dialog.rename.description', { name: dialogData?.name ?? '' })}
{activeDialog === 'delete' && t('sidebarFilesTree.dialog.delete.description', { name: dialogData?.name ?? '' })}
</DialogDescription>
</DialogHeader>
@@ -928,7 +931,7 @@ export const SidebarFilesTree: React.FC = () => {
<Input
value={dialogInputValue}
onChange={(e) => setDialogInputValue(e.target.value)}
placeholder={activeDialog === 'rename' ? 'New name' : 'Name'}
placeholder={activeDialog === 'rename' ? t('sidebarFilesTree.dialog.rename.placeholder') : t('sidebarFilesTree.dialog.namePlaceholder')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
void handleDialogSubmit();
@@ -941,7 +944,7 @@ export const SidebarFilesTree: React.FC = () => {
<DialogFooter>
<Button variant="outline" onClick={() => setActiveDialog(null)} disabled={isDialogSubmitting}>
Cancel
{t('sidebarFilesTree.dialog.cancel')}
</Button>
<Button
variant={activeDialog === 'delete' ? 'destructive' : 'default'}
@@ -949,7 +952,7 @@ export const SidebarFilesTree: React.FC = () => {
disabled={isDialogSubmitting || (activeDialog !== 'delete' && !dialogInputValue.trim())}
>
{isDialogSubmitting ? <RiLoader4Line className="animate-spin" /> : (
activeDialog === 'delete' ? 'Delete' : 'Confirm'
activeDialog === 'delete' ? t('sidebarFilesTree.dialog.delete.confirm') : t('sidebarFilesTree.dialog.confirm')
)}
</Button>
</DialogFooter>
@@ -18,6 +18,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useI18n } from '@/lib/i18n';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar';
import { PaceIndicator } from '@/components/sections/usage/PaceIndicator';
@@ -54,6 +55,7 @@ const SESSIONS_SIDEBAR_MAX_WIDTH = 520;
type VSCodeView = 'sessions' | 'chat' | 'settings';
export const VSCodeLayout: React.FC = () => {
const { t } = useI18n();
const runtimeApis = useRuntimeAPIs();
const viewMode = React.useMemo<'sidebar' | 'editor'>(() => {
@@ -100,8 +102,8 @@ export const VSCodeLayout: React.FC = () => {
if (!currentSessionId) {
return null;
}
return sessions.find((session) => session.id === currentSessionId)?.title || 'Session';
}, [currentSessionId, sessions]);
return sessions.find((session) => session.id === currentSessionId)?.title || t('vscodeLayout.title.sessionFallback');
}, [currentSessionId, sessions, t]);
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
const isSyncingMessages = useViewportStore((state) => state.isSyncing);
const hasActiveSessionWork = useDirectorySync((state) => {
@@ -379,7 +381,7 @@ export const VSCodeLayout: React.FC = () => {
// Editor mode: just chat, no sidebar
<div className="flex flex-col h-full">
<VSCodeHeader
title={sessions.find((session) => session.id === currentSessionId)?.title || 'Chat'}
title={sessions.find((session) => session.id === currentSessionId)?.title || t('vscodeLayout.title.chat')}
showMcp
showContextUsage
/>
@@ -422,15 +424,15 @@ export const VSCodeLayout: React.FC = () => {
onPointerCancel={handleExpandedSidebarResizeEnd}
role="separator"
aria-orientation="vertical"
aria-label="Resize sessions sidebar"
aria-label={t('vscodeLayout.actions.resizeSessionsSidebarAria')}
/>
</div>
{/* Chat content */}
<div className="flex-1 flex flex-col min-w-0">
<VSCodeHeader
title={newSessionDraftOpen && !currentSessionId
? 'New session'
: sessions.find((session) => session.id === currentSessionId)?.title || 'Chat'}
? t('vscodeLayout.title.newSession')
: sessions.find((session) => session.id === currentSessionId)?.title || t('vscodeLayout.title.chat')}
showMcp
showContextUsage
/>
@@ -447,7 +449,7 @@ export const VSCodeLayout: React.FC = () => {
{/* Sessions list view */}
<div className={cn('flex flex-col h-full', currentView !== 'sessions' && 'hidden')}>
<VSCodeHeader
title="Sessions"
title={t('vscodeLayout.title.sessions')}
/>
<div className="flex-1 overflow-hidden">
<SessionSidebar
@@ -463,8 +465,8 @@ export const VSCodeLayout: React.FC = () => {
<div className={cn('flex flex-col h-full', currentView !== 'chat' && 'hidden')}>
<VSCodeHeader
title={newSessionDraftOpen && !currentSessionId
? 'New session'
: sessions.find((session) => session.id === currentSessionId)?.title || 'Chat'}
? t('vscodeLayout.title.newSession')
: sessions.find((session) => session.id === currentSessionId)?.title || t('vscodeLayout.title.chat')}
showBack
onBack={handleBackToSessions}
showMcp
@@ -496,6 +498,7 @@ interface VSCodeHeaderProps {
}
const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, onNewSession, onSettings, onAgentManager, showMcp, showContextUsage, showRateLimits }) => {
const { t } = useI18n();
const getCurrentModel = useConfigStore((s) => s.getCurrentModel);
const getContextUsage = useSessionUIStore((state) => state.getContextUsage);
const quotaResults = useQuotaStore((state) => state.results);
@@ -561,7 +564,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
<button
onClick={onBack}
className="inline-flex h-7 w-7 items-center justify-center text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label="Back to sessions"
aria-label={t('vscodeLayout.actions.backToSessionsAria')}
>
<RiArrowLeftLine className="h-5 w-5" />
</button>
@@ -571,7 +574,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
<button
onClick={onNewSession}
className="inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label="New session"
aria-label={t('vscodeLayout.actions.newSessionAria')}
>
<RiAddLine className="h-5 w-5" />
</button>
@@ -580,7 +583,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
<button
onClick={onAgentManager}
className="inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label="Open Agent Manager"
aria-label={t('vscodeLayout.actions.openAgentManagerAria')}
>
<RiRobot2Line className="h-5 w-5" />
</button>
@@ -601,7 +604,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="Rate limits"
aria-label={t('vscodeLayout.quota.actions.rateLimitsAria')}
className="inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
disabled={isQuotaLoading}
>
@@ -614,7 +617,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
>
<div className="sticky top-0 z-20 bg-[var(--surface-elevated)]">
<DropdownMenuLabel className="flex items-center justify-between gap-3 typography-ui-header font-semibold text-foreground">
<span>Rate limits</span>
<span>{t('vscodeLayout.quota.title')}</span>
<div className="flex items-center gap-1">
<div className="flex items-center rounded-md border border-[var(--interactive-border)] p-0.5">
<button
@@ -627,9 +630,9 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
}`
}
onClick={() => void handleDisplayModeChange('usage')}
aria-label="Show used quota"
aria-label={t('vscodeLayout.quota.actions.showUsedAria')}
>
Used
{t('vscodeLayout.quota.mode.used')}
</button>
<button
type="button"
@@ -641,9 +644,9 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
}`
}
onClick={() => void handleDisplayModeChange('remaining')}
aria-label="Show remaining quota"
aria-label={t('vscodeLayout.quota.actions.showRemainingAria')}
>
Remaining
{t('vscodeLayout.quota.mode.remaining')}
</button>
</div>
<button
@@ -651,7 +654,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
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"
onClick={() => fetchAllQuotas()}
disabled={isQuotaLoading}
aria-label="Refresh rate limits"
aria-label={t('vscodeLayout.quota.actions.refreshAria')}
>
<RiRefreshLine className="h-4 w-4" />
</button>
@@ -659,11 +662,11 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
</DropdownMenuLabel>
</div>
<div className="border-b border-[var(--interactive-border)] px-2 pb-2 typography-micro text-muted-foreground text-[10px]">
Last updated {formatTime(quotaLastUpdated)}
{t('vscodeLayout.quota.lastUpdated', { time: formatTime(quotaLastUpdated) })}
</div>
{!hasRateLimits && (
<DropdownMenuItem className="cursor-default" closeOnClick={false}>
<span className="typography-ui-label text-muted-foreground">No rate limits available.</span>
<span className="typography-ui-label text-muted-foreground">{t('vscodeLayout.quota.noRateLimitsAvailable')}</span>
</DropdownMenuItem>
)}
{rateLimitGroups.map((group, index) => (
@@ -679,7 +682,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
closeOnClick={false}
>
<span className="typography-ui-label text-muted-foreground">
{group.error ?? 'No rate limits reported.'}
{group.error ?? t('vscodeLayout.quota.noRateLimitsReported')}
</span>
</DropdownMenuItem>
) : (
@@ -735,7 +738,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
<button
onClick={onSettings}
className="inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label="Settings"
aria-label={t('vscodeLayout.actions.settingsAria')}
>
<RiSettings3Line className="h-5 w-5" />
</button>
+24 -18
View File
@@ -20,18 +20,22 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useMcpConfigStore } from '@/stores/useMcpConfigStore';
import { computeMcpHealth, useMcpStore } from '@/stores/useMcpStore';
import { McpIcon } from '@/components/icons/McpIcon';
import { useI18n } from '@/lib/i18n';
const statusTooltip = (status: McpStatus | undefined): string => {
if (!status) return 'Unknown';
const statusTooltip = (
status: McpStatus | undefined,
t: (key: 'mcpDropdown.status.unknown' | 'mcpDropdown.status.connected' | 'mcpDropdown.status.failed' | 'mcpDropdown.status.unknownError' | 'mcpDropdown.status.needsAuth' | 'mcpDropdown.status.needsRegistration', params?: { error?: string }) => string
): string => {
if (!status) return t('mcpDropdown.status.unknown');
switch (status.status) {
case 'connected':
return 'Connected';
return t('mcpDropdown.status.connected');
case 'failed':
return `Failed: ${(status as { error?: string }).error || 'Unknown error'}`;
return t('mcpDropdown.status.failed', { error: (status as { error?: string }).error || t('mcpDropdown.status.unknownError') });
case 'needs_auth':
return 'Needs authentication';
return t('mcpDropdown.status.needsAuth');
case 'needs_client_registration':
return `Needs registration: ${(status as { error?: string }).error || ''}`;
return t('mcpDropdown.status.needsRegistration', { error: (status as { error?: string }).error || '' });
default:
return status.status;
}
@@ -61,6 +65,7 @@ interface McpDropdownContentProps {
}
export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active, className }) => {
const { t } = useI18n();
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const directory = currentDirectory ?? null;
const status = useMcpStore((state) => state.getStatusForDirectory(directory));
@@ -113,7 +118,7 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
<div className="border-b border-[var(--interactive-border)]">
<div className="flex items-center justify-between gap-3 px-4 py-2.5">
<div className="min-w-0 flex items-baseline gap-2">
<div className="typography-ui-header font-semibold text-foreground">MCP Servers</div>
<div className="typography-ui-header font-semibold text-foreground">{t('mcpDropdown.title')}</div>
{directory && (
<div className="truncate typography-micro text-muted-foreground">
{directory.split('/').pop() || directory}
@@ -125,7 +130,7 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
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={isSpinning}
onClick={handleRefresh}
aria-label="Refresh"
aria-label={t('mcpDropdown.actions.refreshAria')}
>
<RiRefreshLine className={cn('h-4 w-4', isSpinning && 'animate-spin')} />
</button>
@@ -138,7 +143,7 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
const tone = statusTone(serverStatus);
const isConnected = serverStatus?.status === 'connected';
const isBusy = busyName === serverName;
const tooltip = statusTooltip(serverStatus);
const tooltip = statusTooltip(serverStatus, t);
return (
<div
@@ -191,7 +196,7 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
{sortedNames.length === 0 && (
<div className="px-4 py-5 typography-ui-label text-muted-foreground text-center">
Configure MCP servers in Opencode config.
{t('mcpDropdown.empty.configureInConfig')}
</div>
)}
</div>
@@ -200,6 +205,7 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
};
export const McpDropdown: React.FC<McpDropdownProps> = ({ headerIconButtonClass }) => {
const { t } = useI18n();
const [open, setOpen] = React.useState(false);
const [tooltipOpen, setTooltipOpen] = React.useState(false);
const blockTooltipRef = React.useRef(false);
@@ -278,7 +284,7 @@ export const McpDropdown: React.FC<McpDropdownProps> = ({ headerIconButtonClass
const tone = statusTone(serverStatus);
const isConnected = serverStatus?.status === 'connected';
const isBusy = busyName === serverName;
const tooltip = statusTooltip(serverStatus);
const tooltip = statusTooltip(serverStatus, t);
return (
<div
@@ -344,7 +350,7 @@ export const McpDropdown: React.FC<McpDropdownProps> = ({ headerIconButtonClass
{sortedNames.length === 0 && (
<div className="px-2 py-3 typography-ui-label text-muted-foreground text-center">
Configure MCP servers in Opencode config.
{t('mcpDropdown.empty.configureInConfig')}
</div>
)}
</>
@@ -353,7 +359,7 @@ export const McpDropdown: React.FC<McpDropdownProps> = ({ headerIconButtonClass
const triggerButton = (
<button
type="button"
aria-label="MCP servers"
aria-label={t('mcpDropdown.actions.openAria')}
className={cn(headerIconButtonClass, 'relative')}
onClick={isMobile ? () => setOpen(true) : undefined}
>
@@ -370,7 +376,7 @@ export const McpDropdown: React.FC<McpDropdownProps> = ({ headerIconButtonClass
? 'bg-status-success'
: 'bg-muted-foreground/40'
)}
aria-label="MCP status"
aria-label={t('mcpDropdown.statusAria')}
/>
)}
</button>
@@ -383,18 +389,18 @@ export const McpDropdown: React.FC<McpDropdownProps> = ({ headerIconButtonClass
{triggerButton}
<MobileOverlayPanel
open={open}
title="MCP Servers"
title={t('mcpDropdown.title')}
onClose={() => setOpen(false)}
renderHeader={(closeButton) => (
<div className="flex items-center justify-between px-3 py-2 border-b border-border/40">
<h2 className="typography-ui-label font-semibold text-foreground">MCP Servers</h2>
<h2 className="typography-ui-label font-semibold text-foreground">{t('mcpDropdown.title')}</h2>
<div className="flex items-center gap-1">
<button
type="button"
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover transition-colors"
disabled={isSpinning}
onClick={handleRefresh}
aria-label="Refresh"
aria-label={t('mcpDropdown.actions.refreshAria')}
>
<RiRefreshLine className={cn('h-4 w-4', isSpinning && 'animate-spin')} />
</button>
@@ -428,7 +434,7 @@ export const McpDropdown: React.FC<McpDropdownProps> = ({ headerIconButtonClass
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>
<p>MCP Servers</p>
<p>{t('mcpDropdown.title')}</p>
</TooltipContent>
</Tooltip>
@@ -9,6 +9,7 @@ import {
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { useConfigStore } from '@/stores/useConfigStore';
import { useI18n } from '@/lib/i18n';
export interface AgentSelectorProps {
/** Currently selected agent name (empty string for no agent) */
@@ -34,6 +35,7 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
disabled,
id,
}) => {
const { t } = useI18n();
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
const loadAgents = useConfigStore((state) => state.loadAgents);
const defaultAgentName = useConfigStore((state) => state.currentAgentName);
@@ -91,7 +93,7 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
className,
)}
>
<SelectValue placeholder="Select an agent" />
<SelectValue placeholder={t('multirun.agentSelector.placeholder')} />
</SelectTrigger>
<SelectContent fitContent>
{selectableAgents.length > 0 && (
@@ -12,6 +12,7 @@ import {
import { useGitStore, useGitBranches, useGitLoadingBranches } from '@/stores/useGitStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { useI18n } from '@/lib/i18n';
/** localStorage key matching NewWorktreeDialog */
const LAST_SOURCE_BRANCH_KEY = 'oc:lastWorktreeSourceBranch';
@@ -101,6 +102,7 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
disabled,
id,
}) => {
const { t } = useI18n();
const { localBranches, remoteBranches, isLoading, isGitRepository } = useBranchOptions(directory);
const allBranches = React.useMemo(
() => [...localBranches, ...remoteBranches.map(b => `remotes/${b}`)],
@@ -151,22 +153,22 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
size="lg"
className={className ?? 'w-fit typography-meta text-foreground'}
>
<SelectValue placeholder={isLoading ? 'Loading branches' : 'Select source branch...'} />
<SelectValue placeholder={isLoading ? t('multiRun.branchSelector.status.loadingBranches') : t('multiRun.branchSelector.placeholder.selectSourceBranch')} />
</SelectTrigger>
<SelectContent className="max-h-[280px] max-w-[320px]">
{isLoading ? (
<div className="px-2 py-4 text-center typography-meta text-muted-foreground">
Loading branches...
{t('multiRun.branchSelector.status.loadingBranches')}
</div>
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
<div className="px-2 py-4 text-center typography-meta text-muted-foreground">
No branches found
{t('multiRun.branchSelector.status.noBranchesFound')}
</div>
) : (
<>
{localBranches.length > 0 && (
<SelectGroup>
<SelectLabel className="font-semibold text-foreground">Local branches</SelectLabel>
<SelectLabel className="font-semibold text-foreground">{t('multiRun.branchSelector.groups.localBranches')}</SelectLabel>
{localBranches.map((branch) => (
<SelectItem key={branch} value={branch} className="whitespace-normal break-all">
{branch}
@@ -179,7 +181,7 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
)}
{remoteBranches.length > 0 && (
<SelectGroup>
<SelectLabel className="font-semibold text-foreground">Remote branches</SelectLabel>
<SelectLabel className="font-semibold text-foreground">{t('multiRun.branchSelector.groups.remoteBranches')}</SelectLabel>
{remoteBranches.map((branch) => (
<SelectItem key={`remotes/${branch}`} value={`remotes/${branch}`} className="whitespace-normal break-all">
{branch}
@@ -193,7 +195,7 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
</Select>
{isGitRepository === false && (
<p className="typography-micro text-muted-foreground/70 mt-2">Not in a git repository.</p>
<p className="typography-micro text-muted-foreground/70 mt-2">{t('multiRun.branchSelector.status.notInGitRepository')}</p>
)}
</div>
);
@@ -10,6 +10,7 @@ import { isIMECompositionEvent } from '@/lib/ime';
import { useConfigStore } from '@/stores/useConfigStore';
import { useModelLists } from '@/hooks/useModelLists';
import type { ModelMetadata } from '@/types';
import { useI18n } from '@/lib/i18n';
/** Chip height class - shared between chips and add button */
const CHIP_HEIGHT_CLASS = 'h-7';
@@ -110,11 +111,12 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
onRemove,
onUpdate,
minModels,
addButtonLabel = 'Add model',
addButtonLabel,
showChips = true,
maxModels,
addButtonClassName,
}) => {
const { t } = useI18n();
const providers = useConfigStore((state) => state.providers);
const modelsMetadata = useConfigStore((state) => state.modelsMetadata);
const { favoriteModelsList, recentModelsList } = useModelLists();
@@ -334,7 +336,7 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
onClick={() => setIsOpen(!isOpen)}
>
<RiAddLine className="h-3.5 w-3.5 mr-1" />
{addButtonLabel}
{addButtonLabel ?? t('multirun.modelMultiSelect.actions.addModel')}
</Button>
{isOpen && (() => {
@@ -414,7 +416,7 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
<Input
ref={searchInputRef}
type="text"
placeholder="Search models"
placeholder={t('multirun.modelMultiSelect.search.placeholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
@@ -431,7 +433,7 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
<div className="p-1">
{!hasResults && (
<div className="px-2 py-4 text-center typography-meta text-muted-foreground">
No models found
{t('multirun.modelMultiSelect.search.noResults')}
</div>
)}
@@ -440,7 +442,7 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
<>
<div className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider sticky top-0 z-10 -mx-1 flex items-center gap-2 border-b border-border/30 px-3 py-1.5 [background:linear-gradient(var(--surface-elevated),var(--surface-elevated)),linear-gradient(var(--surface-background),var(--surface-background))]">
<RiStarFill className="h-4 w-4 text-primary" />
Favorites
{t('multirun.modelMultiSelect.sections.favorites')}
</div>
{filteredFavorites.map(({ model, providerID, modelID }) => {
const idx = currentFlatIndex++;
@@ -455,7 +457,7 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
{filteredFavorites.length > 0 && <div className="h-px bg-border/40 my-1" />}
<div className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider sticky top-0 z-10 -mx-1 flex items-center gap-2 border-b border-border/30 px-3 py-1.5 [background:linear-gradient(var(--surface-elevated),var(--surface-elevated)),linear-gradient(var(--surface-background),var(--surface-background))]">
<RiTimeLine className="h-4 w-4" />
Recent
{t('multirun.modelMultiSelect.sections.recent')}
</div>
{filteredRecents.map(({ model, providerID, modelID }) => {
const idx = currentFlatIndex++;
@@ -491,7 +493,7 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
{/* 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('multirun.modelMultiSelect.keyboard.hint')}
</div>
</div>
);
@@ -544,11 +546,11 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
variantValue === DEFAULT_VARIANT_VALUE ? 'text-muted-foreground' : 'text-[color:var(--status-info)]'
)}
/>
<SelectValue placeholder="Thinking" />
<SelectValue placeholder={t('multirun.modelMultiSelect.variant.placeholder')} />
</SelectTrigger>
<SelectContent fitContent>
<SelectItem value={DEFAULT_VARIANT_VALUE} className="pr-2 [&>span:first-child]:hidden">
Default
{t('multirun.modelMultiSelect.variant.default')}
</SelectItem>
{variantKeys.map((variant) => (
<SelectItem key={variant} value={variant} className="pr-2 [&>span:first-child]:hidden">
@@ -568,7 +570,9 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
{/* Validation hint */}
{minModels !== undefined && selectedModels.length < minModels && (
<p className="typography-micro text-muted-foreground">
Select from {minModels} {maxModels !== undefined ? `to ${maxModels} models` : ''}.
{maxModels !== undefined
? t('multirun.modelMultiSelect.validation.minToMax', { min: minModels, max: maxModels })
: t('multirun.modelMultiSelect.validation.minOnly', { min: minModels })}
</p>
)}
</div>
@@ -26,6 +26,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import type { ProjectEntry } from '@/lib/api/types';
import { startDesktopWindowDrag } from '@/lib/desktopNative';
import { useI18n } from '@/lib/i18n';
/** Max file size in bytes (10MB) */
const MAX_FILE_SIZE = 10 * 1024 * 1024;
@@ -93,6 +94,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
onCancel,
isWindowed = false,
}) => {
const { t } = useI18n();
const [name, setName] = React.useState('');
const [prompt, setPrompt] = React.useState(() => initialPrompt ?? '');
const [selectedModels, setSelectedModels] = React.useState<ModelSelectionWithId[]>([]);
@@ -358,7 +360,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.size > MAX_FILE_SIZE) {
toast.error(`File "${file.name}" is too large (max 10MB)`);
toast.error(t('multirun.launcher.toast.fileTooLarge', { fileName: file.name }));
continue;
}
@@ -382,12 +384,16 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
attachedCount++;
} catch (error) {
console.error('File attach failed', error);
toast.error(`Failed to attach "${file.name}"`);
toast.error(t('multirun.launcher.toast.attachFailed', { fileName: file.name }));
}
}
if (attachedCount > 0) {
toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`);
toast.success(
attachedCount === 1
? t('multirun.launcher.toast.attachedSingle', { count: attachedCount })
: t('multirun.launcher.toast.attachedPlural', { count: attachedCount })
);
}
if (fileInputRef.current) {
@@ -596,7 +602,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
)}
style={{ borderColor: 'var(--interactive-border)' }}
>
<h1 className="typography-ui-label font-medium">New Multi-Run</h1>
<h1 className="typography-ui-label font-medium">{t('multirun.launcher.title')}</h1>
{onCancel && (
<div className="absolute right-0 flex items-center pr-3">
<Tooltip delayDuration={500}>
@@ -604,14 +610,14 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
<button
type="button"
onClick={onCancel}
aria-label="Close (Esc)"
aria-label={t('multirun.launcher.actions.closeEsc')}
className="inline-flex h-9 w-9 items-center justify-center p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary app-region-no-drag"
>
<RiCloseLine className="h-5 w-5" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Close (Esc)</p>
<p>{t('multirun.launcher.actions.closeEsc')}</p>
</TooltipContent>
</Tooltip>
</div>
@@ -628,7 +634,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-3">
{/* Project */}
<div className="flex flex-col gap-1">
<FieldLabel htmlFor="multirun-project" required>Project</FieldLabel>
<FieldLabel htmlFor="multirun-project" required>{t('multirun.launcher.project.label')}</FieldLabel>
{projects.length > 0 ? (
<Select
value={selectedProjectId ?? undefined}
@@ -638,7 +644,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
{selectedProject ? (
<SelectValue>{renderProjectLabel(selectedProject)}</SelectValue>
) : (
<SelectValue placeholder="Select project" />
<SelectValue placeholder={t('multirun.launcher.project.placeholder')} />
)}
</SelectTrigger>
<SelectContent fitContent>
@@ -650,7 +656,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
</SelectContent>
</Select>
) : (
<p className="typography-micro text-muted-foreground py-2">Add a project first.</p>
<p className="typography-micro text-muted-foreground py-2">{t('multirun.launcher.project.empty')}</p>
)}
</div>
@@ -659,15 +665,15 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
<FieldLabel
htmlFor="group-name"
required
info={<InfoTip>Used for worktree directory and branch names</InfoTip>}
info={<InfoTip>{t('multirun.launcher.groupName.info')}</InfoTip>}
>
Group name
{t('multirun.launcher.groupName.label')}
</FieldLabel>
<Input
id="group-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="feature-auth, bugfix-login"
placeholder={t('multirun.launcher.groupName.placeholder')}
className="typography-meta w-full"
required
/>
@@ -677,9 +683,9 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
<div className="flex flex-col gap-1">
<FieldLabel
htmlFor="multirun-worktree-base-branch"
info={<InfoTip>New branch created from this base per model</InfoTip>}
info={<InfoTip>{t('multirun.launcher.baseBranch.info')}</InfoTip>}
>
Base branch
{t('multirun.launcher.baseBranch.label')}
</FieldLabel>
<BranchSelector
directory={selectedProjectDirectory}
@@ -693,9 +699,9 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
<div className="flex flex-col gap-1">
<FieldLabel
htmlFor="multirun-agent"
info={<InfoTip>Agent used for all runs. Defaults to your configured agent.</InfoTip>}
info={<InfoTip>{t('multirun.launcher.agent.info')}</InfoTip>}
>
Agent
{t('multirun.launcher.agent.label')}
</FieldLabel>
<AgentSelector
value={selectedAgent}
@@ -710,7 +716,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
<CollapsibleTrigger className="w-full flex items-center gap-2 py-1.5 px-2 -mx-2 rounded-lg hover:bg-[var(--interactive-hover)]/50 transition-colors group">
<RiTerminalLine className="h-3.5 w-3.5 text-muted-foreground/70" />
<span className="typography-meta font-medium text-muted-foreground group-hover:text-foreground transition-colors">
Setup commands
{t('multirun.launcher.setupCommands.label')}
</span>
{configuredSetupCount > 0 && (
<span
@@ -733,7 +739,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
<CollapsibleContent>
<div className="pt-2 space-y-1.5">
{isLoadingSetupCommands ? (
<p className="typography-meta text-muted-foreground/70 px-2">Loading...</p>
<p className="typography-meta text-muted-foreground/70 px-2">{t('multirun.launcher.setupCommands.loading')}</p>
) : (
<>
{setupCommands.map((command, index) => (
@@ -745,7 +751,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
newCommands[index] = e.target.value;
setSetupCommands(newCommands);
}}
placeholder="bun install"
placeholder={t('multirun.launcher.setupCommands.commandPlaceholder')}
className="h-8 flex-1 font-mono text-xs"
/>
<button
@@ -755,7 +761,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
setSetupCommands(newCommands);
}}
className="flex-shrink-0 flex h-8 w-8 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('multirun.launcher.setupCommands.removeCommandAria')}
>
<RiCloseLine className="h-3.5 w-3.5" />
</button>
@@ -767,7 +773,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
className="flex items-center gap-1 typography-meta text-muted-foreground hover:text-foreground transition-colors px-1"
>
<RiAddLine className="h-3 w-3" />
Add command
{t('multirun.launcher.setupCommands.addCommand')}
</button>
</>
)}
@@ -777,7 +783,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
{/* ── Prompt ── */}
<div className="flex flex-col gap-1.5">
<FieldLabel htmlFor="prompt" required>Prompt</FieldLabel>
<FieldLabel htmlFor="prompt" required>{t('multirun.launcher.prompt.label')}</FieldLabel>
<div className="relative">
<Textarea
id="prompt"
@@ -790,7 +796,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
updateAutocompleteState(nextPrompt, cursorPosition);
}}
onKeyDown={handlePromptKeyDown}
placeholder="Enter the prompt to send to all models..."
placeholder={t('multirun.launcher.prompt.placeholder')}
className="typography-meta min-h-[100px] max-h-[300px] resize-none overflow-y-auto field-sizing-content"
required
/>
@@ -847,10 +853,10 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
className="inline-flex items-center gap-1 h-6 px-2 rounded-md typography-micro text-muted-foreground hover:text-foreground hover:bg-[var(--interactive-hover)]/50 transition-colors"
>
<RiAttachment2 className="h-3 w-3" />
Attach
{t('multirun.launcher.attachments.attach')}
</button>
</TooltipTrigger>
<TooltipContent>Same files sent to all runs</TooltipContent>
<TooltipContent>{t('multirun.launcher.attachments.tooltip')}</TooltipContent>
</Tooltip>
{attachedFiles.map((file) => (
@@ -886,9 +892,9 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
<div className="flex flex-col gap-1.5">
<FieldLabel
required
info={<InfoTip>Select 2{MAX_MODELS} models. Same model can be added multiple times.</InfoTip>}
info={<InfoTip>{t('multirun.launcher.models.info', { max: MAX_MODELS })}</InfoTip>}
>
Models
{t('multirun.launcher.models.label')}
</FieldLabel>
<ModelMultiSelect
selectedModels={selectedModels}
@@ -927,7 +933,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
size="sm"
onClick={onCancel}
>
Cancel
{t('multirun.launcher.actions.cancel')}
</Button>
<Button
type="submit"
@@ -935,9 +941,9 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
disabled={!isValid || isSubmitting}
>
{isSubmitting ? (
'Creating...'
t('multirun.launcher.actions.creating')
) : (
<>Start ({selectedModels.length} models)</>
<>{t('multirun.launcher.actions.startWithModelCount', { count: selectedModels.length })}</>
)}
</Button>
</div>
@@ -9,6 +9,7 @@ import { restartDesktopApp } from '@/lib/desktop';
import { cn } from '@/lib/utils';
import { RemoteConnectionForm } from './RemoteConnectionForm';
import { desktopHostsGet, desktopHostsSet } from '@/lib/desktopHosts';
import { useI18n } from '@/lib/i18n';
const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash';
const DOCS_URL = 'https://opencode.ai/docs';
@@ -21,7 +22,7 @@ type ChooserScreenProps = {
onCliAvailable?: () => void;
};
function BashCommand({ onCopy }: { onCopy: () => void }) {
function BashCommand({ onCopy, copyTitle }: { onCopy: () => void; copyTitle: string }) {
return (
<div className="flex items-center justify-center gap-3">
<code>
@@ -34,7 +35,7 @@ function BashCommand({ onCopy }: { onCopy: () => void }) {
<button
onClick={onCopy}
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
title="Copy to clipboard"
title={copyTitle}
>
<RiFileCopyLine className="h-4 w-4" />
</button>
@@ -45,6 +46,7 @@ function BashCommand({ onCopy }: { onCopy: () => void }) {
const HINT_DELAY_MS = 30000;
export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
const { t } = useI18n();
const [copied, setCopied] = React.useState(false);
const [showHint, setShowHint] = React.useState(false);
const [isDesktopApp, setIsDesktopApp] = React.useState(false);
@@ -149,7 +151,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
try {
const selected = await tauri.dialog.open({
title: 'Select opencode binary',
title: t('onboarding.localSetup.dialog.selectOpencodeBinary'),
multiple: false,
directory: false,
});
@@ -159,7 +161,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
} catch {
// ignore
}
}, [isDesktopApp]);
}, [isDesktopApp, t]);
// Persist the user's first choice (local or remote)
const persistFirstChoice = React.useCallback(async (choice: 'local' | 'remote') => {
@@ -226,14 +228,14 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
}
onCliAvailable?.();
} else {
setCheckError('OpenCode CLI is not ready yet. Please confirm installation is complete and try again.');
setCheckError(t('onboarding.localSetup.errors.cliNotReady'));
}
} catch (err) {
setCheckError(err instanceof Error ? err.message : 'Detection failed');
setCheckError(err instanceof Error ? err.message : t('onboarding.localSetup.errors.detectionFailed'));
} finally {
setIsChecking(false);
}
}, [checkCliAvailability, onCliAvailable, persistFirstChoice]);
}, [checkCliAvailability, onCliAvailable, persistFirstChoice, t]);
const docsUrl = platform === 'windows' ? WINDOWS_WSL_DOCS_URL : DOCS_URL;
const binaryPlaceholder =
@@ -251,10 +253,10 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
<div className="w-full space-y-4 text-center">
<div className="space-y-4">
<h1 className="text-3xl font-semibold tracking-tight text-foreground">
Welcome to OpenChamber
{t('onboarding.chooser.title')}
</h1>
<p className="text-muted-foreground">
Choose how you want to connect to get started.
{t('onboarding.chooser.description')}
</p>
</div>
@@ -270,7 +272,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
)}
onClick={() => setActiveTab('local')}
>
Local Install
{t('onboarding.chooser.tabs.localInstall')}
</button>
<button
type="button"
@@ -282,7 +284,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
)}
onClick={handleChooseRemote}
>
Connect Remote
{t('onboarding.chooser.tabs.connectRemote')}
</button>
</div>
)}
@@ -299,11 +301,11 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
<>
{platform === 'windows' && (
<div className="mx-auto max-w-2xl rounded-lg border border-border bg-background/50 p-4 text-left">
<div className="text-sm text-foreground">Windows setup (WSL recommended)</div>
<div className="text-sm text-foreground">{t('onboarding.localSetup.windows.title')}</div>
<ol className="mt-2 list-decimal space-y-1 pl-5 text-sm text-muted-foreground">
<li>Install WSL (if needed) with <code className="text-foreground/80">wsl --install</code> in PowerShell.</li>
<li>Run the install command below inside your WSL terminal.</li>
<li>If OpenChamber does not detect OpenCode automatically, set the binary path below.</li>
<li>{t('onboarding.localSetup.windows.stepInstallWsl')} <code className="text-foreground/80">wsl --install</code> {t('onboarding.localSetup.windows.stepInstallWslSuffix')}</li>
<li>{t('onboarding.localSetup.windows.stepRunInstallInWsl')}</li>
<li>{t('onboarding.localSetup.windows.stepSetBinaryPath')}</li>
</ol>
</div>
)}
@@ -313,10 +315,10 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
{copied ? (
<div className="flex items-center justify-center gap-2" style={{ color: 'var(--status-success)' }}>
<RiCheckLine className="h-4 w-4" />
Copied to clipboard
{t('onboarding.common.status.copiedToClipboard')}
</div>
) : (
<BashCommand onCopy={handleCopy} />
<BashCommand onCopy={handleCopy} copyTitle={t('onboarding.common.copyToClipboard')} />
)}
</div>
</div>
@@ -327,7 +329,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1 justify-center"
>
{platform === 'windows' ? 'View Windows + WSL documentation' : 'View documentation'}
{platform === 'windows' ? t('onboarding.localSetup.docs.windows') : t('onboarding.localSetup.docs.default')}
<RiExternalLinkLine className="h-3 w-3" />
</a>
@@ -345,17 +347,17 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
className="w-full max-w-xs"
size="lg"
>
{isChecking ? 'Checking...' : "I've completed installation, check and continue"}
{isChecking ? t('onboarding.localSetup.actions.checking') : t('onboarding.localSetup.actions.checkAndContinue')}
</Button>
<p className="text-xs text-muted-foreground">
Click to check if OpenCode CLI is available. If successful, you'll automatically enter the main screen.
{t('onboarding.localSetup.helper.checkAndContinue')}
</p>
</div>
<div className="mx-auto w-full max-w-xl pt-4">
<div className="space-y-2">
<div className="text-sm text-muted-foreground">Already installed? Set the OpenCode CLI path:</div>
<div className="text-sm text-muted-foreground">{t('onboarding.localSetup.field.alreadyInstalled')}</div>
<div className="flex gap-2">
<Input
value={opencodeBinary}
@@ -370,17 +372,17 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
onClick={handleBrowse}
disabled={isRetrying || !isDesktopApp || !isTauriShell()}
>
Browse
{t('onboarding.localSetup.actions.browse')}
</Button>
<Button
type="button"
onClick={handleApplyPath}
disabled={isRetrying}
>
Apply
{t('onboarding.localSetup.actions.apply')}
</Button>
</div>
<div className="text-xs text-muted-foreground/70">Saves to OpenChamber settings and reloads OpenCode configuration.</div>
<div className="text-xs text-muted-foreground/70">{t('onboarding.localSetup.helper.saveAndReload')}</div>
</div>
</div>
</>
@@ -394,22 +396,22 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
{platform === 'windows' ? (
<>
<p className="text-sm text-muted-foreground/70">
On Windows, install and run OpenCode in WSL for best compatibility.
{t('onboarding.localSetup.windows.hintInstallInWsl')}
</p>
<p className="text-sm text-muted-foreground/70">
If detection fails, set a native path (<code className="text-foreground/70">opencode.cmd</code>/<code className="text-foreground/70">opencode.exe</code>), <code className="text-foreground/70">wsl.exe</code>, or <code className="text-foreground/70">wsl:/usr/local/bin/opencode</code>.
{t('onboarding.localSetup.windows.hintDetectionFailed')}
</p>
</>
) : (
<>
<p className="text-sm text-muted-foreground/70">
Already installed? Make sure <code className="text-foreground/70">opencode</code> is in your PATH
{t('onboarding.localSetup.hint.ensurePath')}
</p>
<p className="text-sm text-muted-foreground/70">
or set <code className="text-foreground/70">OPENCODE_BINARY</code> environment variable.
{t('onboarding.localSetup.hint.setEnv')}
</p>
<p className="text-sm text-muted-foreground/70">
If you see <code className="text-foreground/70">env: node: No such file or directory</code> or <code className="text-foreground/70">env: bun: No such file or directory</code>, install that runtime or ensure it is on PATH.
{t('onboarding.localSetup.hint.missingRuntime')}
</p>
</>
)}
@@ -1,6 +1,7 @@
import React from 'react';
import { RiRefreshLine, RiServerLine, RiMacbookLine } from '@remixicon/react';
import { Button } from '@/components/ui/button';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { redactSensitiveUrl } from '@/lib/desktopHosts';
import {
@@ -39,7 +40,21 @@ export function DesktopConnectionRecovery({
onUseRemote,
isRetrying = false,
}: DesktopConnectionRecoveryProps) {
const { t } = useI18n();
const config = getDesktopRecoveryConfig(variant, hostLabel, hostUrl);
const retryLabelKey = (config.retryLabelKey ?? 'onboarding.desktopRecovery.actions.retryConnection') as Parameters<typeof t>[0];
const descriptionParams = React.useMemo(() => {
if (config.descriptionParams?.host) {
return config.descriptionParams;
}
if (variant === 'remote-unreachable') {
return { host: t('onboarding.desktopRecovery.placeholders.remoteServer') };
}
if (variant === 'remote-wrong-service') {
return { host: t('onboarding.desktopRecovery.placeholders.unknownServer') };
}
return undefined;
}, [config.descriptionParams, t, variant]);
return (
<div className="flex flex-col items-center justify-center h-full p-8">
@@ -58,17 +73,20 @@ export function DesktopConnectionRecovery({
</div>
</div>
<h1 className="typography-ui-header text-xl font-semibold text-foreground">
{config.title}
{t(config.titleKey as Parameters<typeof t>[0])}
</h1>
<p className="text-muted-foreground text-sm max-w-sm">
{config.description}
{t(
config.descriptionKey as Parameters<typeof t>[0],
descriptionParams
)}
</p>
</div>
{/* Host info if available */}
{hostUrl && (variant === 'remote-unreachable' || variant === 'remote-wrong-service') && (
<div className="rounded-lg border border-border bg-background/50 p-3">
<div className="text-xs text-muted-foreground mb-1">Server Address</div>
<div className="text-xs text-muted-foreground mb-1">{t('onboarding.remoteConnection.field.serverAddress')}</div>
<div className="font-mono text-sm text-foreground truncate">{redactSensitiveUrl(hostUrl)}</div>
</div>
)}
@@ -82,7 +100,9 @@ export function DesktopConnectionRecovery({
className="w-full"
>
<RiRefreshLine className={cn('h-4 w-4', isRetrying && 'animate-spin')} />
{isRetrying ? 'Retrying…' : (config.retryLabel ?? 'Retry Connection')}
{isRetrying
? t('onboarding.desktopRecovery.actions.retrying')
: t(retryLabelKey)}
</Button>
)}
@@ -95,7 +115,7 @@ export function DesktopConnectionRecovery({
className="flex-1"
>
<RiMacbookLine className="h-4 w-4" />
{config.useLocalLabel}
{t(config.useLocalLabelKey as Parameters<typeof t>[0])}
</Button>
)}
@@ -107,7 +127,7 @@ export function DesktopConnectionRecovery({
className="flex-1"
>
<RiServerLine className="h-4 w-4" />
{config.useRemoteLabel}
{t(config.useRemoteLabelKey as Parameters<typeof t>[0])}
</Button>
)}
</div>
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
import { updateDesktopSettings } from '@/lib/persistence';
import { copyTextToClipboard } from '@/lib/clipboard';
import { restartDesktopApp } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash';
const DOCS_URL = 'https://opencode.ai/docs';
@@ -24,7 +25,7 @@ type LocalSetupScreenProps = {
onSwitchToRemote?: () => void;
};
function BashCommand({ onCopy }: { onCopy: () => void }) {
function BashCommand({ onCopy, copyTitle }: { onCopy: () => void; copyTitle: string }) {
return (
<div className="flex items-center justify-center gap-3">
<code>
@@ -37,7 +38,7 @@ function BashCommand({ onCopy }: { onCopy: () => void }) {
<button
onClick={onCopy}
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
title="Copy to clipboard"
title={copyTitle}
>
<RiFileCopyLine className="h-4 w-4" />
</button>
@@ -53,6 +54,7 @@ export function LocalSetupScreen({
isFromRecovery = false,
onSwitchToRemote,
}: LocalSetupScreenProps) {
const { t } = useI18n();
const [copied, setCopied] = React.useState(false);
const [showHint, setShowHint] = React.useState(false);
const [isDesktopApp, setIsDesktopApp] = React.useState(false);
@@ -156,7 +158,7 @@ export function LocalSetupScreen({
try {
const selected = await tauri.dialog.open({
title: 'Select opencode binary',
title: t('onboarding.localSetup.dialog.selectOpencodeBinary'),
multiple: false,
directory: false,
});
@@ -166,7 +168,7 @@ export function LocalSetupScreen({
} catch {
// ignore
}
}, [isDesktopApp]);
}, [isDesktopApp, t]);
const handleApplyPath = React.useCallback(async () => {
setIsRetrying(true);
@@ -205,14 +207,14 @@ export function LocalSetupScreen({
// CLI is available, proceed to main screen
onCliAvailable?.();
} else {
setCheckError('OpenCode CLI is not ready yet. Please confirm installation is complete and try again.');
setCheckError(t('onboarding.localSetup.errors.cliNotReady'));
}
} catch (err) {
setCheckError(err instanceof Error ? err.message : 'Detection failed');
setCheckError(err instanceof Error ? err.message : t('onboarding.localSetup.errors.detectionFailed'));
} finally {
setIsChecking(false);
}
}, [checkCliAvailability, onCliAvailable]);
}, [checkCliAvailability, onCliAvailable, t]);
const docsUrl = platform === 'windows' ? WINDOWS_WSL_DOCS_URL : DOCS_URL;
const binaryPlaceholder =
@@ -234,26 +236,26 @@ export function LocalSetupScreen({
onClick={onBack}
className="p-0 text-muted-foreground hover:text-foreground"
>
&larr; Back
{t('onboarding.common.actions.back')}
</Button>
</div>
<div className="space-y-4">
<h1 className="text-3xl font-semibold tracking-tight text-foreground">
Setting Up OpenCode
{t('onboarding.localSetup.title')}
</h1>
<p className="text-muted-foreground">
Install OpenCode CLI to continue.
{t('onboarding.localSetup.description')}
</p>
</div>
{platform === 'windows' && (
<div className="mx-auto max-w-2xl rounded-lg border border-border bg-background/50 p-4 text-left">
<div className="text-sm text-foreground">Windows setup (WSL recommended)</div>
<div className="text-sm text-foreground">{t('onboarding.localSetup.windows.title')}</div>
<ol className="mt-2 list-decimal space-y-1 pl-5 text-sm text-muted-foreground">
<li>Install WSL (if needed) with <code className="text-foreground/80">wsl --install</code> in PowerShell.</li>
<li>Run the install command below inside your WSL terminal.</li>
<li>If OpenChamber does not detect OpenCode automatically, set the binary path below.</li>
<li>{t('onboarding.localSetup.windows.stepInstallWsl')} <code className="text-foreground/80">wsl --install</code> {t('onboarding.localSetup.windows.stepInstallWslSuffix')}</li>
<li>{t('onboarding.localSetup.windows.stepRunInstallInWsl')}</li>
<li>{t('onboarding.localSetup.windows.stepSetBinaryPath')}</li>
</ol>
</div>
)}
@@ -263,10 +265,10 @@ export function LocalSetupScreen({
{copied ? (
<div className="flex items-center justify-center gap-2" style={{ color: 'var(--status-success)' }}>
<RiCheckLine className="h-4 w-4" />
Copied to clipboard
{t('onboarding.common.status.copiedToClipboard')}
</div>
) : (
<BashCommand onCopy={handleCopy} />
<BashCommand onCopy={handleCopy} copyTitle={t('onboarding.common.copyToClipboard')} />
)}
</div>
</div>
@@ -277,7 +279,7 @@ export function LocalSetupScreen({
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1 justify-center"
>
{platform === 'windows' ? 'View Windows + WSL documentation' : 'View documentation'}
{platform === 'windows' ? t('onboarding.localSetup.docs.windows') : t('onboarding.localSetup.docs.default')}
<RiExternalLinkLine className="h-3 w-3" />
</a>
@@ -295,17 +297,17 @@ export function LocalSetupScreen({
className="w-full max-w-xs"
size="lg"
>
{isChecking ? 'Checking...' : "I've completed installation, check and continue"}
{isChecking ? t('onboarding.localSetup.actions.checking') : t('onboarding.localSetup.actions.checkAndContinue')}
</Button>
<p className="text-xs text-muted-foreground">
Click to check if OpenCode CLI is available. If successful, you'll automatically enter the main screen.
{t('onboarding.localSetup.helper.checkAndContinue')}
</p>
</div>
<div className="mx-auto w-full max-w-xl pt-4">
<div className="space-y-2">
<div className="text-sm text-muted-foreground">Already installed? Set the OpenCode CLI path:</div>
<div className="text-sm text-muted-foreground">{t('onboarding.localSetup.field.alreadyInstalled')}</div>
<div className="flex gap-2">
<Input
value={opencodeBinary}
@@ -320,30 +322,30 @@ export function LocalSetupScreen({
onClick={handleBrowse}
disabled={isRetrying || !isDesktopApp || !isTauriShell()}
>
Browse
{t('onboarding.localSetup.actions.browse')}
</Button>
<Button
type="button"
onClick={handleApplyPath}
disabled={isRetrying}
>
Apply
{t('onboarding.localSetup.actions.apply')}
</Button>
</div>
<div className="text-xs text-muted-foreground/70">Saves to OpenChamber settings and reloads OpenCode configuration.</div>
<div className="text-xs text-muted-foreground/70">{t('onboarding.localSetup.helper.saveAndReload')}</div>
</div>
</div>
{isFromRecovery && onSwitchToRemote && (
<div className="text-center pt-4">
<p className="text-sm text-muted-foreground mb-2">
Prefer to use a remote server?
{t('onboarding.localSetup.remotePreference')}
</p>
<Button
variant="link"
onClick={onSwitchToRemote}
>
Connect to Remote Server &rarr;
{t('onboarding.localSetup.actions.connectRemoteServer')}
</Button>
</div>
)}
@@ -354,22 +356,22 @@ export function LocalSetupScreen({
{platform === 'windows' ? (
<>
<p className="text-sm text-muted-foreground/70">
On Windows, install and run OpenCode in WSL for best compatibility.
{t('onboarding.localSetup.windows.hintInstallInWsl')}
</p>
<p className="text-sm text-muted-foreground/70">
If detection fails, set a native path (<code className="text-foreground/70">opencode.cmd</code>/<code className="text-foreground/70">opencode.exe</code>), <code className="text-foreground/70">wsl.exe</code>, or <code className="text-foreground/70">wsl:/usr/local/bin/opencode</code>.
{t('onboarding.localSetup.windows.hintDetectionFailed')}
</p>
</>
) : (
<>
<p className="text-sm text-muted-foreground/70">
Already installed? Make sure <code className="text-foreground/70">opencode</code> is in your PATH
{t('onboarding.localSetup.hint.ensurePath')}
</p>
<p className="text-sm text-muted-foreground/70">
or set <code className="text-foreground/70">OPENCODE_BINARY</code> environment variable.
{t('onboarding.localSetup.hint.setEnv')}
</p>
<p className="text-sm text-muted-foreground/70">
If you see <code className="text-foreground/70">env: node: No such file or directory</code> or <code className="text-foreground/70">env: bun: No such file or directory</code>, install that runtime or ensure it is on PATH.
{t('onboarding.localSetup.hint.missingRuntime')}
</p>
</>
)}
@@ -9,6 +9,7 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { isTauriShell } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
type ConnectionState = 'idle' | 'testing' | 'success' | 'error';
@@ -30,16 +31,16 @@ export interface RemoteConnectionFormProps {
type ProbeStatus = HostProbeResult['status'] | null;
function getProbeStatusMessage(status: ProbeStatus): string | null {
function getProbeStatusMessageKey(status: ProbeStatus): string | null {
switch (status) {
case 'ok':
return null; // Success is shown separately
case 'auth':
return 'Server requires authentication. You can still connect, but may need to provide credentials.';
return 'onboarding.remoteConnection.probe.authMessage';
case 'wrong-service':
return 'Server responded but is not running OpenChamber. Verify the address points to an OpenChamber server.';
return 'onboarding.remoteConnection.probe.wrongServiceMessage';
case 'unreachable':
return 'Server is unreachable. Check your network connection and verify the server address.';
return 'onboarding.remoteConnection.probe.unreachableMessage';
default:
return null;
}
@@ -58,6 +59,7 @@ export function RemoteConnectionForm({
onConnect,
onSwitchToLocal,
}: RemoteConnectionFormProps) {
const { t } = useI18n();
const [url, setUrl] = useState(initialUrl);
const [label, setLabel] = useState(initialLabel);
const [state, setState] = useState<ConnectionState>('idle');
@@ -89,10 +91,10 @@ export function RemoteConnectionForm({
setProbeResult(result);
setState(result.status === 'ok' ? 'success' : 'error');
} catch (err) {
setError(err instanceof Error ? err.message : 'Connection test failed');
setError(err instanceof Error ? err.message : t('onboarding.remoteConnection.errors.connectionTestFailed'));
setState('error');
}
}, [normalizedUrl]);
}, [normalizedUrl, t]);
const handleConnect = useCallback(async () => {
if (!normalizedUrl) return;
@@ -144,16 +146,16 @@ export function RemoteConnectionForm({
await tauri?.core?.invoke?.('desktop_restart');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save connection');
setError(err instanceof Error ? err.message : t('onboarding.remoteConnection.errors.failedToSaveConnection'));
setState('error');
}
}, [normalizedUrl, label, onConnect]);
}, [normalizedUrl, label, onConnect, t]);
const isTesting = state === 'testing';
const canTest = normalizedUrl !== null && !isTesting;
const canConnect = normalizedUrl !== null && !isTesting && !isBlockingStatus(probeResult?.status ?? null);
const probeMessage = getProbeStatusMessage(probeResult?.status ?? null);
const probeMessageKey = getProbeStatusMessageKey(probeResult?.status ?? null);
const isSuccess = probeResult?.status === 'ok';
const isAuth = probeResult?.status === 'auth';
const isBlocking = isBlockingStatus(probeResult?.status ?? null);
@@ -164,47 +166,47 @@ export function RemoteConnectionForm({
{showBackButton && (
<div className="flex items-center">
<Button variant="ghost" onClick={onBack} className="p-0 text-muted-foreground hover:text-foreground">
&larr; Back
{t('onboarding.common.actions.back')}
</Button>
</div>
)}
<div className="space-y-2 text-center">
<h1 className="typography-ui-header text-xl font-semibold text-foreground">
{isRecoveryMode ? 'Connect to a Different Server' : 'Connect to Remote Server'}
</h1>
<p className="text-muted-foreground text-sm">
<h1 className="typography-ui-header text-xl font-semibold text-foreground">
{isRecoveryMode
? 'Enter the address of an OpenChamber server to connect to.'
: 'Enter the address of an OpenChamber server to connect to.'}
</p>
</div>
? t('onboarding.remoteConnection.titleRecovery')
: t('onboarding.remoteConnection.title')}
</h1>
<p className="text-muted-foreground text-sm">
{t('onboarding.remoteConnection.description')}
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<label htmlFor="remote-url" className="text-sm text-foreground">
Server Address
{t('onboarding.remoteConnection.field.serverAddress')}
</label>
<Input
id="remote-url"
type="url"
value={url}
onChange={handleUrlChange}
placeholder="https://your-server.example.com:4096"
placeholder={t('onboarding.remoteConnection.field.serverAddressPlaceholder')}
disabled={isTesting}
/>
</div>
<div className="space-y-2">
<label htmlFor="remote-label" className="text-sm text-foreground">
Name (optional)
{t('onboarding.remoteConnection.field.nameOptional')}
</label>
<Input
id="remote-label"
type="text"
value={label}
onChange={handleLabelChange}
placeholder="My Remote Server"
placeholder={t('onboarding.remoteConnection.field.namePlaceholder')}
disabled={isTesting}
/>
</div>
@@ -219,7 +221,7 @@ export function RemoteConnectionForm({
color: 'var(--status-success)',
}}
>
Connected successfully ({probeResult.latencyMs}ms)
{t('onboarding.remoteConnection.status.connectedSuccessfully', { latencyMs: probeResult.latencyMs })}
</div>
)}
@@ -232,7 +234,7 @@ export function RemoteConnectionForm({
color: 'var(--status-warning)',
}}
>
Server requires authentication. You can still connect.
{t('onboarding.remoteConnection.status.authWarning')}
</div>
)}
@@ -246,13 +248,13 @@ export function RemoteConnectionForm({
}}
>
<div>
<div className="font-semibold mb-1">Connection Failed</div>
<div className="opacity-90">{probeMessage}</div>
<div className="font-semibold mb-1">{t('onboarding.remoteConnection.status.connectionFailed')}</div>
<div className="opacity-90">{probeMessageKey ? t(probeMessageKey as Parameters<typeof t>[0]) : null}</div>
</div>
<div className="text-xs opacity-80">
{probeResult.status === 'unreachable'
? 'Suggestions: Check the server address, verify the server is running, or check your network connection.'
: 'Suggestions: Verify the URL points to an OpenChamber server, or contact the server administrator.'}
? t('onboarding.remoteConnection.status.suggestionsUnreachable')
: t('onboarding.remoteConnection.status.suggestionsWrongService')}
</div>
</div>
)}
@@ -276,20 +278,20 @@ export function RemoteConnectionForm({
onClick={handleTest}
disabled={!canTest}
>
{isTesting ? 'Testing\u2026' : 'Test Connection'}
{isTesting ? t('onboarding.remoteConnection.actions.testing') : t('onboarding.remoteConnection.actions.testConnection')}
</Button>
<Button
onClick={handleConnect}
disabled={!canConnect}
>
Connect &amp; Restart
{t('onboarding.remoteConnection.actions.connectAndRestart')}
</Button>
</div>
{/* Suggested actions when connection is blocked */}
{isBlocking && (
<div className="flex flex-col gap-2 pt-2 border-t border-border">
<div className="text-xs text-muted-foreground text-center">What would you like to do?</div>
<div className="text-xs text-muted-foreground text-center">{t('onboarding.remoteConnection.actions.whatToDo')}</div>
<div className="flex gap-2">
<Button
variant="outline"
@@ -297,7 +299,7 @@ export function RemoteConnectionForm({
onClick={onBack}
className="flex-1"
>
Choose Different Server
{t('onboarding.remoteConnection.actions.chooseDifferentServer')}
</Button>
{!isRecoveryMode && onSwitchToLocal && (
<Button
@@ -306,7 +308,7 @@ export function RemoteConnectionForm({
onClick={onSwitchToLocal}
className="flex-1"
>
Use Local Instead
{t('onboarding.remoteConnection.actions.useLocalInstead')}
</Button>
)}
</div>
@@ -10,15 +10,21 @@ export type RecoveryVariant =
export type DesktopRecoveryConfig = {
title: string;
description: string;
titleKey: string;
descriptionKey: string;
descriptionParams?: Record<string, string>;
iconKey: 'local' | 'remote';
showRetry: boolean;
retryLabel?: string;
retryLabelKey?: string;
showUseLocal: boolean;
showUseRemote: boolean;
/** Label for the "use local" primary action button */
useLocalLabel: string;
useLocalLabelKey: string;
/** Label for the "use remote" primary action button */
useRemoteLabel: string;
useRemoteLabelKey: string;
};
function formatHostDisplay(hostLabel?: string, hostUrl?: string): string | undefined {
@@ -36,27 +42,35 @@ export function getDesktopRecoveryConfig(
case 'local-unavailable':
return {
title: 'Local OpenCode Unavailable',
description:
'OpenCode CLI could not be started or is not installed. Install OpenCode or connect to a remote server instead.',
description: 'OpenCode CLI could not be started or is not installed. Install OpenCode or connect to a remote server instead.',
titleKey: 'onboarding.desktopRecovery.localUnavailable.title',
descriptionKey: 'onboarding.desktopRecovery.localUnavailable.description',
iconKey: 'local',
showRetry: true,
retryLabel: 'Retry Local',
retryLabelKey: 'onboarding.desktopRecovery.localUnavailable.retry',
showUseLocal: true,
showUseRemote: true,
useLocalLabel: 'Set Up Local',
useLocalLabelKey: 'onboarding.desktopRecovery.localUnavailable.useLocal',
useRemoteLabel: 'Use Remote',
useRemoteLabelKey: 'onboarding.desktopRecovery.common.useRemote',
};
case 'remote-missing':
return {
title: 'No Default Connection',
description: 'Your saved default connection could not be found. Choose how you want to connect.',
titleKey: 'onboarding.desktopRecovery.noDefaultConnection.title',
descriptionKey: 'onboarding.desktopRecovery.noDefaultConnection.description',
iconKey: 'local',
showRetry: false,
showUseLocal: true,
showUseRemote: true,
useLocalLabel: 'Use Local',
useLocalLabelKey: 'onboarding.desktopRecovery.common.useLocal',
useRemoteLabel: 'Use Remote',
useRemoteLabelKey: 'onboarding.desktopRecovery.common.useRemote',
};
case 'remote-unreachable': {
@@ -64,13 +78,19 @@ export function getDesktopRecoveryConfig(
return {
title: 'Remote Server Unreachable',
description: `Could not connect to "${host || 'the remote server'}". Check your network connection and verify the server address.`,
titleKey: 'onboarding.desktopRecovery.remoteUnreachable.title',
descriptionKey: 'onboarding.desktopRecovery.remoteUnreachable.description',
descriptionParams: host ? { host } : undefined,
iconKey: 'remote',
showRetry: true,
retryLabel: 'Retry Connection',
retryLabelKey: 'onboarding.desktopRecovery.remoteUnreachable.retry',
showUseLocal: true,
showUseRemote: true,
useLocalLabel: 'Use Local',
useLocalLabelKey: 'onboarding.desktopRecovery.common.useLocal',
useRemoteLabel: 'Use Remote',
useRemoteLabelKey: 'onboarding.desktopRecovery.common.useRemote',
};
}
@@ -79,12 +99,17 @@ export function getDesktopRecoveryConfig(
return {
title: 'Incompatible Server',
description: `The server at "${host || 'unknown'}" is not running OpenChamber. Verify the address points to an OpenChamber server.`,
titleKey: 'onboarding.desktopRecovery.incompatibleServer.title',
descriptionKey: 'onboarding.desktopRecovery.incompatibleServer.description',
descriptionParams: host ? { host } : undefined,
iconKey: 'remote',
showRetry: false,
showUseLocal: true,
showUseRemote: true,
useLocalLabel: 'Use Local',
useLocalLabelKey: 'onboarding.desktopRecovery.common.useLocal',
useRemoteLabel: 'Use Remote',
useRemoteLabelKey: 'onboarding.desktopRecovery.common.useRemote',
};
}
@@ -92,12 +117,16 @@ export function getDesktopRecoveryConfig(
return {
title: 'No Default Connection',
description: 'Your saved default connection could not be found. Choose how you want to connect.',
titleKey: 'onboarding.desktopRecovery.noDefaultConnection.title',
descriptionKey: 'onboarding.desktopRecovery.noDefaultConnection.description',
iconKey: 'local',
showRetry: false,
showUseLocal: true,
showUseRemote: true,
useLocalLabel: 'Use Local',
useLocalLabelKey: 'onboarding.desktopRecovery.common.useLocal',
useRemoteLabel: 'Use Remote',
useRemoteLabelKey: 'onboarding.desktopRecovery.common.useRemote',
};
default: {
@@ -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>
)}

Some files were not shown because too many files have changed in this diff Show More