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
@@ -0,0 +1,128 @@
---
name: locale-ui-patterns
description: Use when creating or modifying OpenChamber UI text, labels, buttons, placeholders, aria labels, empty states, toasts, dialogs, settings copy, navigation labels, or any user-facing strings.
---
# Locale UI Patterns
## Core Rule
User-facing UI text must go through `@/lib/i18n`; do not hardcode English strings in components.
Use this skill for any React UI change that adds or edits visible text, accessible labels, placeholders, tooltips, toasts, dialogs, settings labels, navigation labels, or empty/error states.
## Required Flow
1. Add or reuse a key in `packages/ui/src/lib/i18n/messages/en.ts`.
2. Add the same key to every non-English dictionary in `packages/ui/src/lib/i18n/messages/`.
3. In components, call `const { t } = useI18n()` from `@/lib/i18n` and render `t('key')`.
4. For locale names or language picker labels, use `label(locale)` from `useI18n()`.
5. Keep locale state in `packages/ui/src/lib/i18n/*`; do not add locale fields to broad stores like `useUIStore`.
6. Do not remount the app to update language. Components must re-render through `useI18n()`.
## Component Usage Rules
- Import from `@/lib/i18n`, not deep files.
- Keep `t(...)` calls inside React render/hook scope so locale changes re-render text.
- Do not resolve translated text at module scope.
- For static option arrays, store `labelKey` / `descriptionKey`; resolve with `t(...)` inside the component.
- For non-React helpers, pass translated strings in from the component or pass `t` explicitly.
## Key Style
Use stable semantic keys, not English text as keys.
Keys should describe location + UI role + meaning. They should not encode current copy wording.
Use existing nearby naming when extending a surface. If no nearby pattern exists, choose a short path that mirrors the UI ownership.
Namespaces like `layout.*`, `settings.*`, `chat.*`, `git.*`, `session.*`, `toast.*`, and `dialog.*` are examples, not a fixed exhaustive list.
Good:
```ts
'settings.appearance.language.label': 'Language'
'layout.mainTab.chat': 'Chat'
'chat.input.placeholder': 'Ask OpenChamber...'
```
Bad:
```ts
'Language': 'Language'
'chatLabel': 'Chat'
'askOpenChamberDotDotDot': 'Ask OpenChamber...'
```
Avoid overly generic keys unless the text is truly global and context-independent. Prefer specific keys when button meaning can vary by surface.
## Parameters
Use `{name}` placeholders for dynamic values.
```ts
'toast.language.changed': 'Language changed to {language}'
```
```tsx
t('toast.language.changed', { language: label(locale) })
```
Do not pass grammar fragments as params. Never use params like `{suffix}`, `{plural}`, `{article}`, `{prefix}`, `{dateSuffix}`, or pieces of words/sentences.
Bad:
```tsx
t('dialog.delete.description', { count, suffix: count === 1 ? '' : 's' })
```
Good:
```tsx
count === 1
? t('dialog.delete.descriptionSingle', { count })
: t('dialog.delete.descriptionPlural', { count })
```
Plural/count-dependent text must use separate complete-message keys unless all supported locales can use one identical complete sentence. Placeholders are only for real values (`{count}`, `{name}`, `{path}`), not grammar.
Optional clauses must also be complete-message keys. Do not build a sentence by injecting a translated phrase into another translated sentence.
Bad:
```tsx
t('dialog.delete.description', {
dateLabel: date ? t('dialog.delete.dateSuffix', { date }) : '',
})
```
Good:
```tsx
date
? t('dialog.delete.descriptionWithDate', { count, date })
: t('dialog.delete.description', { count })
```
## What Counts As UI Text
- Button and menu labels
- Settings labels and descriptions
- Placeholder text
- Tooltip content
- Dialog titles/descriptions/actions
- Toast title/description/action labels
- Empty/error/loading states
- `aria-label`, `title`, image `alt` text when user-facing
## Exceptions
Do not translate:
- Product names: `OpenChamber`, `OpenCode`, `GitHub`
- Protocol/tool acronyms: `MCP`, `SSE`, `WebSocket`, `API`
- Model/provider names
- File paths, command names, environment variables
- User/generated content
## Review Checklist
- No new hardcoded user-facing English in changed UI files.
- Every new key exists in all dictionaries.
- No locale state added to broad/shared stores.
- No full app remount for locale changes.
- Locale switch preserves current UI state.
@@ -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>

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