Add i18n foundation and translations (#1027)
* feat: add i18n foundation * feat: localize sessions sidebar * Localize multirun/scheduled tasks and fix dialog dropdown interactions * localize git sidebar surface and add zh-CN keys * feat(ui): localize context panel, diff/plan views, and context sidebar content * fix(config): resolve user config home via fs/home before embedded home * localize header/chat UI and complete model/worktree panel strings * localize worktree + github issue/pr dialog flows * localize settings sections and split settings i18n dictionaries * localize additional settings sections and sidebars * localize more settings pages and dialogs * fix settings select trigger localization * localize tunnel settings ui surface * localize additional settings sections * localize keyboard shortcuts labels in settings * localize terminal and utility dialogs surfaces * feat(i18n): localize remaining UI strings * Add Ukrainian locale * Add Spanish locale * Add Brazilian Portuguese locale * Polish locale translations
This commit is contained in:
committed by
GitHub
parent
87db2ea210
commit
7d7285655d
@@ -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'),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
← 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 →
|
||||
{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">
|
||||
← 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 & 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 ·{' '}
|
||||
<code className="text-foreground">!`cmd`</code> shell output ·{' '}
|
||||
<code className="text-foreground">@file</code> file contents
|
||||
<code className="text-foreground">$ARGUMENTS</code> {t('settings.commands.page.templateHint.userInput')} ·{' '}
|
||||
<code className="text-foreground">!`cmd`</code> {t('settings.commands.page.templateHint.shellOutput')} ·{' '}
|
||||
<code className="text-foreground">@file</code> {t('settings.commands.page.templateHint.fileContents')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -327,7 +329,7 @@ export const CommandsPage: React.FC = () => {
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
{isSaving ? t('settings.common.actions.saving') : t('settings.common.actions.saveChanges')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,12 +23,14 @@ import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface CommandsSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
}
|
||||
|
||||
export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const [renameDialogCommand, setRenameDialogCommand] = React.useState<Command | null>(null);
|
||||
const [renameNewName, setRenameNewName] = React.useState('');
|
||||
const [confirmActionCommand, setConfirmActionCommand] = React.useState<Command | null>(null);
|
||||
@@ -90,7 +92,7 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
|
||||
const handleDeleteCommand = async (command: Command) => {
|
||||
if (isCommandBuiltIn(command)) {
|
||||
toast.error('Built-in commands cannot be deleted');
|
||||
toast.error(t('settings.commands.sidebar.toast.builtInCannotDelete'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -122,15 +124,15 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
|
||||
if (success) {
|
||||
if (confirmActionType === 'delete') {
|
||||
toast.success(`Command "${confirmActionCommand.name}" deleted successfully`);
|
||||
toast.success(t('settings.commands.sidebar.toast.commandDeleted', { name: confirmActionCommand.name }));
|
||||
} else {
|
||||
toast.success(`Command "${confirmActionCommand.name}" reset to default`);
|
||||
toast.success(t('settings.commands.sidebar.toast.commandReset', { name: confirmActionCommand.name }));
|
||||
}
|
||||
closeConfirmActionDialog();
|
||||
} else if (confirmActionType === 'delete') {
|
||||
toast.error('Failed to delete command');
|
||||
toast.error(t('settings.commands.sidebar.toast.deleteFailed'));
|
||||
} else {
|
||||
toast.error('Failed to reset command');
|
||||
toast.error(t('settings.commands.sidebar.toast.resetFailed'));
|
||||
}
|
||||
|
||||
setIsConfirmActionPending(false);
|
||||
@@ -171,7 +173,7 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
const sanitizedName = renameNewName.trim().replace(/\s+/g, '-');
|
||||
|
||||
if (!sanitizedName) {
|
||||
toast.error('Command name is required');
|
||||
toast.error(t('settings.commands.sidebar.toast.commandNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -181,7 +183,7 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
}
|
||||
|
||||
if (commands.some((cmd) => cmd.name === sanitizedName)) {
|
||||
toast.error('A command with this name already exists');
|
||||
toast.error(t('settings.commands.sidebar.toast.commandExists'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -201,10 +203,10 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
toast.success(`Command renamed to "${sanitizedName}"`);
|
||||
setSelectedCommand(sanitizedName);
|
||||
} else {
|
||||
toast.error('Failed to remove old command after rename');
|
||||
toast.error(t('settings.commands.sidebar.toast.removeOldAfterRenameFailed'));
|
||||
}
|
||||
} else {
|
||||
toast.error('Failed to rename command');
|
||||
toast.error(t('settings.commands.sidebar.toast.renameFailed'));
|
||||
}
|
||||
|
||||
setRenameDialogCommand(null);
|
||||
@@ -216,10 +218,10 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">Commands</h2>
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.commands.sidebar.title')}</h2>
|
||||
<SettingsProjectSelector className="mb-3" />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">Total {commandOnlyItems.length}</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.commands.sidebar.total', { count: commandOnlyItems.length })}</span>
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
|
||||
@@ -234,15 +236,15 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
{commandOnlyItems.length === 0 ? (
|
||||
<div className="py-12 px-4 text-center text-muted-foreground">
|
||||
<RiTerminalBoxLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
||||
<p className="typography-ui-label font-medium">No commands configured</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Use the + button above to create one</p>
|
||||
<p className="typography-ui-label font-medium">{t('settings.commands.sidebar.empty.title')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.commands.sidebar.empty.description')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{builtInCommands.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Built-in Commands
|
||||
{t('settings.commands.sidebar.section.builtIn')}
|
||||
</div>
|
||||
{[...builtInCommands].sort((a, b) => a.name.localeCompare(b.name)).map((command) => (
|
||||
<CommandListItem
|
||||
@@ -266,7 +268,7 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
{customCommands.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pb-1.5 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Custom Commands
|
||||
{t('settings.commands.sidebar.section.custom')}
|
||||
</div>
|
||||
{[...customCommands].sort((a, b) => a.name.localeCompare(b.name)).map((command) => (
|
||||
<CommandListItem
|
||||
@@ -301,11 +303,11 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{confirmActionType === 'delete' ? 'Delete Command' : 'Reset Command'}</DialogTitle>
|
||||
<DialogTitle>{confirmActionType === 'delete' ? t('settings.commands.sidebar.dialog.deleteTitle') : t('settings.commands.sidebar.dialog.resetTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{confirmActionType === 'delete'
|
||||
? `Are you sure you want to delete command "${confirmActionCommand?.name}"?`
|
||||
: `Are you sure you want to reset command "${confirmActionCommand?.name}" to its default configuration?`}
|
||||
? t('settings.commands.sidebar.dialog.deleteDescription', { name: confirmActionCommand?.name ?? '' })
|
||||
: t('settings.commands.sidebar.dialog.resetDescription', { name: confirmActionCommand?.name ?? '' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -315,10 +317,10 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
onClick={closeConfirmActionDialog}
|
||||
disabled={isConfirmActionPending}
|
||||
>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleConfirmAction} disabled={isConfirmActionPending}>
|
||||
{confirmActionType === 'delete' ? 'Delete' : 'Reset'}
|
||||
{confirmActionType === 'delete' ? t('settings.common.actions.delete') : t('settings.common.actions.reset')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -328,15 +330,15 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
<Dialog open={renameDialogCommand !== null} onOpenChange={(open) => !open && setRenameDialogCommand(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename Command</DialogTitle>
|
||||
<DialogTitle>{t('settings.commands.sidebar.renameDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter a new name for the command "/{renameDialogCommand?.name}"
|
||||
{t('settings.commands.sidebar.renameDialog.description', { name: renameDialogCommand?.name ?? '' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={renameNewName}
|
||||
onChange={(e) => setRenameNewName(e.target.value)}
|
||||
placeholder="New command name..."
|
||||
placeholder={t('settings.commands.sidebar.renameDialog.placeholder')}
|
||||
className="text-foreground placeholder:text-muted-foreground"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
@@ -350,10 +352,10 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
variant="ghost"
|
||||
onClick={() => setRenameDialogCommand(null)}
|
||||
>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleRenameCommand}>
|
||||
Rename
|
||||
{t('settings.common.actions.rename')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -385,6 +387,7 @@ const CommandListItem: React.FC<CommandListItemProps> = ({
|
||||
isMenuOpen,
|
||||
onMenuOpenChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isMobile = isMobileDeviceViaCSS();
|
||||
return (
|
||||
<div
|
||||
@@ -409,7 +412,7 @@ const CommandListItem: React.FC<CommandListItemProps> = ({
|
||||
</span>
|
||||
{(command.scope || isCommandBuiltIn(command)) && (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
{isCommandBuiltIn(command) ? 'system' : command.scope}
|
||||
{isCommandBuiltIn(command) ? t('settings.agents.sidebar.badge.system') : command.scope}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -439,7 +442,7 @@ const CommandListItem: React.FC<CommandListItemProps> = ({
|
||||
}}
|
||||
>
|
||||
<RiEditLine className="h-4 w-4 mr-px" />
|
||||
Rename
|
||||
{t('settings.common.actions.rename')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
@@ -450,7 +453,7 @@ const CommandListItem: React.FC<CommandListItemProps> = ({
|
||||
}}
|
||||
>
|
||||
<RiFileCopyLine className="h-4 w-4 mr-px" />
|
||||
Duplicate
|
||||
{t('settings.common.actions.duplicate')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{onReset && (
|
||||
@@ -461,7 +464,7 @@ const CommandListItem: React.FC<CommandListItemProps> = ({
|
||||
}}
|
||||
>
|
||||
<RiRestartLine className="h-4 w-4 mr-px" />
|
||||
Reset
|
||||
{t('settings.common.actions.reset')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
@@ -474,7 +477,7 @@ const CommandListItem: React.FC<CommandListItemProps> = ({
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-px" />
|
||||
Delete
|
||||
{t('settings.common.actions.delete')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
RiLock2Line,
|
||||
} from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const PROFILE_COLORS = [
|
||||
{ key: 'keyword', label: 'Green', cssVar: 'var(--syntax-keyword)' },
|
||||
@@ -56,6 +57,7 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
profileId,
|
||||
importData,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
getProfileById,
|
||||
createProfile,
|
||||
@@ -130,11 +132,11 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!userName.trim() || !userEmail.trim()) {
|
||||
toast.error('User name and email are required');
|
||||
toast.error(t('settings.gitIdentities.editor.toast.userNameEmailRequired'));
|
||||
return;
|
||||
}
|
||||
if (authType === 'token' && !host.trim()) {
|
||||
toast.error('Host is required for token-based authentication');
|
||||
toast.error(t('settings.gitIdentities.editor.toast.hostRequiredForToken'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -161,14 +163,14 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
}
|
||||
|
||||
if (success) {
|
||||
toast.success(isNewProfile ? 'Profile created' : 'Profile updated');
|
||||
toast.success(isNewProfile ? t('settings.gitIdentities.editor.toast.profileCreated') : t('settings.gitIdentities.editor.toast.profileUpdated'));
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.error(isNewProfile ? 'Failed to create profile' : 'Failed to update profile');
|
||||
toast.error(isNewProfile ? t('settings.gitIdentities.editor.toast.createProfileFailed') : t('settings.gitIdentities.editor.toast.updateProfileFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving profile:', error);
|
||||
toast.error('An error occurred while saving');
|
||||
toast.error(t('settings.gitIdentities.editor.toast.saveUnexpectedError'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -180,15 +182,15 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
try {
|
||||
const success = await deleteProfile(profileId);
|
||||
if (success) {
|
||||
toast.success('Profile deleted');
|
||||
toast.success(t('settings.gitIdentities.editor.toast.profileDeleted'));
|
||||
setIsDeleteDialogOpen(false);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.error('Failed to delete profile');
|
||||
toast.error(t('settings.gitIdentities.editor.toast.deleteProfileFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting profile:', error);
|
||||
toast.error('An error occurred while deleting');
|
||||
toast.error(t('settings.gitIdentities.editor.toast.deleteUnexpectedError'));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
@@ -200,12 +202,12 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
}, [color]);
|
||||
|
||||
const title = importData
|
||||
? 'Import Credential'
|
||||
? t('settings.gitIdentities.editor.title.importCredential')
|
||||
: isNewProfile
|
||||
? 'New Identity'
|
||||
? t('settings.gitIdentities.editor.title.newIdentity')
|
||||
: isGlobalProfile
|
||||
? 'Global Identity'
|
||||
: (selectedProfile?.name || 'Edit Identity');
|
||||
? t('settings.gitIdentities.editor.title.globalIdentity')
|
||||
: (selectedProfile?.name || t('settings.gitIdentities.editor.title.editIdentity'));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -215,10 +217,10 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isGlobalProfile
|
||||
? 'System-wide Git identity (read-only)'
|
||||
? t('settings.gitIdentities.editor.description.globalReadOnly')
|
||||
: isNewProfile
|
||||
? 'Create a new Git identity profile'
|
||||
: 'Edit identity profile settings'}
|
||||
? t('settings.gitIdentities.editor.description.newProfile')
|
||||
: t('settings.gitIdentities.editor.description.editProfile')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -227,17 +229,17 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
{!isGlobalProfile && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="typography-ui-label text-foreground block mb-1.5">Profile Name</label>
|
||||
<label className="typography-ui-label text-foreground block mb-1.5">{t('settings.gitIdentities.editor.field.profileName')}</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Work Profile, Personal, etc."
|
||||
placeholder={t('settings.gitIdentities.editor.field.profileNamePlaceholder')}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="typography-ui-label text-foreground">Color</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.gitIdentities.editor.field.color')}</span>
|
||||
<div className="flex gap-1.5">
|
||||
{PROFILE_COLORS.map((c) => (
|
||||
<button
|
||||
@@ -258,7 +260,7 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="typography-ui-label text-foreground">Icon</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.gitIdentities.editor.field.icon')}</span>
|
||||
<div className="flex gap-1.5">
|
||||
{PROFILE_ICONS.map((i) => {
|
||||
const IconComponent = i.Icon;
|
||||
@@ -294,21 +296,21 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<label className="typography-ui-label text-foreground">User Name</label>
|
||||
<label className="typography-ui-label text-foreground">{t('settings.gitIdentities.editor.field.userName')}</label>
|
||||
{!isGlobalProfile && <span className="text-[var(--status-error)] text-xs">*</span>}
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
The name that will appear in Git commit messages.
|
||||
{t('settings.gitIdentities.editor.field.userNameTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
value={userName}
|
||||
onChange={(e) => setUserName(e.target.value)}
|
||||
placeholder="John Doe"
|
||||
placeholder={t('settings.gitIdentities.editor.field.userNamePlaceholder')}
|
||||
required={!isGlobalProfile}
|
||||
readOnly={isGlobalProfile}
|
||||
disabled={isGlobalProfile}
|
||||
@@ -318,14 +320,14 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<label className="typography-ui-label text-foreground">Email Address</label>
|
||||
<label className="typography-ui-label text-foreground">{t('settings.gitIdentities.editor.field.emailAddress')}</label>
|
||||
{!isGlobalProfile && <span className="text-[var(--status-error)] text-xs">*</span>}
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Should match your email in GitHub/GitLab for proper attribution.
|
||||
{t('settings.gitIdentities.editor.field.emailAddressTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -333,7 +335,7 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
type="email"
|
||||
value={userEmail}
|
||||
onChange={(e) => setUserEmail(e.target.value)}
|
||||
placeholder="john@example.com"
|
||||
placeholder={t('settings.gitIdentities.editor.field.emailAddressPlaceholder')}
|
||||
required={!isGlobalProfile}
|
||||
readOnly={isGlobalProfile}
|
||||
disabled={isGlobalProfile}
|
||||
@@ -348,7 +350,7 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
<div className="border-t border-border/40" />
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="typography-ui-label text-foreground">Auth Method</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.gitIdentities.editor.field.authMethod')}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm"
|
||||
type="button"
|
||||
@@ -364,7 +366,7 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
aria-pressed={authType === 'token'}
|
||||
onClick={() => setAuthType('token')}
|
||||
>
|
||||
<RiKeyLine className="w-3.5 h-3.5 mr-1" /> Token
|
||||
<RiKeyLine className="w-3.5 h-3.5 mr-1" /> {t('settings.gitIdentities.editor.field.authToken')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -372,20 +374,20 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
{authType === 'ssh' && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<label className="typography-ui-label text-foreground">SSH Key Path</label>
|
||||
<label className="typography-ui-label text-foreground">{t('settings.gitIdentities.editor.field.sshKeyPath')}</label>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Optional path to private key. e.g. ~/.ssh/id_ed25519
|
||||
{t('settings.gitIdentities.editor.field.sshKeyPathTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
value={sshKey}
|
||||
onChange={(e) => setSshKey(e.target.value)}
|
||||
placeholder="~/.ssh/id_ed25519"
|
||||
placeholder={t('settings.gitIdentities.editor.field.sshKeyPathPlaceholder')}
|
||||
className="h-8 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
@@ -394,21 +396,21 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
{authType === 'token' && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<label className="typography-ui-label text-foreground">Host</label>
|
||||
<label className="typography-ui-label text-foreground">{t('settings.gitIdentities.editor.field.host')}</label>
|
||||
<span className="text-[var(--status-error)] text-xs">*</span>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Token will be read from ~/.git-credentials for this host.
|
||||
{t('settings.gitIdentities.editor.field.hostTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
placeholder="github.com"
|
||||
placeholder={t('settings.gitIdentities.editor.field.hostPlaceholder')}
|
||||
required
|
||||
className="h-8 font-mono text-xs"
|
||||
/>
|
||||
@@ -427,15 +429,15 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
onClick={() => setIsDeleteDialogOpen(true)}
|
||||
className="text-[var(--status-error)] hover:text-[var(--status-error)] border-[var(--status-error)]/30 hover:bg-[var(--status-error)]/10 mr-auto"
|
||||
>
|
||||
<RiDeleteBinLine className="w-3.5 h-3.5 mr-1" /> Delete
|
||||
<RiDeleteBinLine className="w-3.5 h-3.5 mr-1" /> {t('settings.common.actions.delete')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)} className="text-foreground hover:bg-interactive-hover hover:text-foreground">
|
||||
{isGlobalProfile ? 'Close' : 'Cancel'}
|
||||
{isGlobalProfile ? t('settings.gitIdentities.editor.actions.close') : t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
{!isGlobalProfile && (
|
||||
<Button size="sm" onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? 'Saving...' : isNewProfile ? 'Create' : 'Save'}
|
||||
{isSaving ? t('settings.common.actions.saving') : isNewProfile ? t('settings.gitIdentities.editor.actions.create') : t('settings.gitIdentities.editor.actions.save')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
@@ -449,17 +451,17 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Profile</DialogTitle>
|
||||
<DialogTitle>{t('settings.gitIdentities.page.deleteDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{selectedProfile?.name || name}"?
|
||||
{t('settings.gitIdentities.page.deleteDialog.description', { name: selectedProfile?.name || name })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setIsDeleteDialogOpen(false)} disabled={isDeleting}>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => void handleConfirmDelete()} disabled={isDeleting}>
|
||||
Delete
|
||||
{t('settings.common.actions.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -34,6 +34,7 @@ import { GitHubSettings } from '@/components/sections/openchamber/GitHubSettings
|
||||
import { GitIdentityEditorDialog } from './GitIdentityEditorDialog';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const ICON_MAP: Record<string, React.ComponentType<{ className?: string; style?: React.CSSProperties }>> = {
|
||||
branch: RiGitBranchLine,
|
||||
@@ -53,6 +54,7 @@ const COLOR_MAP: Record<string, string> = {
|
||||
};
|
||||
|
||||
export const GitPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
profiles,
|
||||
globalIdentity,
|
||||
@@ -91,10 +93,10 @@ export const GitPage: React.FC = () => {
|
||||
const next = defaultGitIdentityId === profileId ? null : profileId;
|
||||
const ok = await setDefaultGitIdentityId(next);
|
||||
if (!ok) {
|
||||
toast.error('Failed to update default identity');
|
||||
toast.error(t('settings.gitIdentities.page.toast.updateDefaultFailed'));
|
||||
return;
|
||||
}
|
||||
toast.success(next ? 'Default identity updated' : 'Default identity unset');
|
||||
toast.success(next ? t('settings.gitIdentities.page.toast.defaultUpdated') : t('settings.gitIdentities.page.toast.defaultUnset'));
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
@@ -102,10 +104,10 @@ export const GitPage: React.FC = () => {
|
||||
setIsDeletePending(true);
|
||||
const success = await deleteProfile(deleteDialogProfile.id);
|
||||
if (success) {
|
||||
toast.success(`Profile "${deleteDialogProfile.name}" deleted`);
|
||||
toast.success(t('settings.gitIdentities.page.toast.profileDeleted', { name: deleteDialogProfile.name }));
|
||||
setDeleteDialogProfile(null);
|
||||
} else {
|
||||
toast.error('Failed to delete profile');
|
||||
toast.error(t('settings.gitIdentities.page.toast.deleteProfileFailed'));
|
||||
}
|
||||
setIsDeletePending(false);
|
||||
};
|
||||
@@ -120,10 +122,10 @@ export const GitPage: React.FC = () => {
|
||||
<div className="border-t border-border/40 pt-6">
|
||||
<div className="mb-3 px-1 flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">Identities</h3>
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">{t('settings.gitIdentities.page.section.title')}</h3>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => openEditor('new')}>
|
||||
<RiAddLine className="w-3.5 h-3.5 mr-1" /> New
|
||||
<RiAddLine className="w-3.5 h-3.5 mr-1" /> {t('settings.common.badge.new')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -157,8 +159,8 @@ export const GitPage: React.FC = () => {
|
||||
{!globalIdentity && profiles.length === 0 && unimportedCredentials.length === 0 && (
|
||||
<div className="py-8 px-4 text-center text-muted-foreground">
|
||||
<RiShieldKeyholeLine className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||
<p className="typography-ui-label">No identities configured</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Create one to manage Git author settings per project</p>
|
||||
<p className="typography-ui-label">{t('settings.gitIdentities.page.empty.title')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.gitIdentities.page.empty.description')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -167,7 +169,7 @@ export const GitPage: React.FC = () => {
|
||||
<>
|
||||
<div className="px-4 py-2 border-t border-[var(--surface-subtle)]">
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
Found in ~/.git-credentials
|
||||
{t('settings.gitIdentities.page.discoveredCredentials.title')}
|
||||
</span>
|
||||
</div>
|
||||
{unimportedCredentials.map((cred, i) => (
|
||||
@@ -202,17 +204,17 @@ export const GitPage: React.FC = () => {
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Profile</DialogTitle>
|
||||
<DialogTitle>{t('settings.gitIdentities.page.deleteDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{deleteDialogProfile?.name}"?
|
||||
{t('settings.gitIdentities.page.deleteDialog.description', { name: deleteDialogProfile?.name ?? '' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setDeleteDialogProfile(null)} disabled={isDeletePending}>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => void handleConfirmDelete()} disabled={isDeletePending}>
|
||||
Delete
|
||||
{t('settings.common.actions.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -242,6 +244,7 @@ const IdentityRow: React.FC<IdentityRowProps> = ({
|
||||
isReadOnly,
|
||||
hasBorder,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const IconComponent = ICON_MAP[profile.icon || 'branch'] || RiGitBranchLine;
|
||||
const iconColor = COLOR_MAP[profile.color || ''];
|
||||
const authType = profile.authType || 'ssh';
|
||||
@@ -267,12 +270,12 @@ const IdentityRow: React.FC<IdentityRowProps> = ({
|
||||
</span>
|
||||
{isDefault && (
|
||||
<span className="typography-micro text-primary bg-primary/12 px-1 rounded flex-shrink-0 leading-none pb-px border border-primary/25">
|
||||
default
|
||||
{t('settings.gitIdentities.page.badge.default')}
|
||||
</span>
|
||||
)}
|
||||
{isReadOnly && (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
system
|
||||
{t('settings.agents.sidebar.badge.system')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -295,7 +298,7 @@ const IdentityRow: React.FC<IdentityRowProps> = ({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-fit min-w-28">
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onToggleDefault(); }}>
|
||||
{isDefault ? 'Unset default' : 'Set as default'}
|
||||
{isDefault ? t('settings.gitIdentities.page.actions.unsetDefault') : t('settings.gitIdentities.page.actions.setAsDefault')}
|
||||
</DropdownMenuItem>
|
||||
{!isReadOnly && onDelete && (
|
||||
<DropdownMenuItem
|
||||
@@ -303,7 +306,7 @@ const IdentityRow: React.FC<IdentityRowProps> = ({
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-px" />
|
||||
Delete
|
||||
{t('settings.common.actions.delete')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
@@ -321,6 +324,7 @@ interface DiscoveredRowProps {
|
||||
}
|
||||
|
||||
const DiscoveredRow: React.FC<DiscoveredRowProps> = ({ credential, onImport, hasBorder }) => {
|
||||
const { t } = useI18n();
|
||||
const parts = credential.host.split('/');
|
||||
const displayName = parts.length >= 3 ? parts[parts.length - 1] : credential.host;
|
||||
const isRepoSpecific = credential.host.includes('/');
|
||||
@@ -340,7 +344,7 @@ const DiscoveredRow: React.FC<DiscoveredRowProps> = ({ credential, onImport, has
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" onClick={onImport} className="gap-1 shrink-0">
|
||||
<RiDownloadLine className="h-3 w-3" />
|
||||
Import
|
||||
{t('settings.gitIdentities.page.actions.import')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,129 +14,130 @@ import {
|
||||
type MagicPromptId,
|
||||
} from '@/lib/magicPrompts';
|
||||
import { useMagicPromptsStore } from '@/stores/useMagicPromptsStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type PromptBlock = {
|
||||
id: MagicPromptId;
|
||||
title: string;
|
||||
titleKey: string;
|
||||
};
|
||||
|
||||
type PromptPageConfig = {
|
||||
title: string;
|
||||
description: string;
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
blocks: PromptBlock[];
|
||||
};
|
||||
|
||||
const PROMPT_PAGE_MAP: Record<string, PromptPageConfig> = {
|
||||
'git.commit.generate': {
|
||||
title: 'Commit Generation',
|
||||
description: 'Prompts used for commit message generation: visible user message + hidden instructions.',
|
||||
titleKey: 'settings.magicPrompts.page.group.gitCommitGenerate.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.gitCommitGenerate.description',
|
||||
blocks: [
|
||||
{ id: 'git.commit.generate.visible', title: 'Visible Prompt' },
|
||||
{ id: 'git.commit.generate.instructions', title: 'Instructions' },
|
||||
{ id: 'git.commit.generate.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'git.commit.generate.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'git.pr.generate': {
|
||||
title: 'PR Generation',
|
||||
description: 'Prompts used for PR title/body generation: visible user message + hidden instructions.',
|
||||
titleKey: 'settings.magicPrompts.page.group.gitPrGenerate.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.gitPrGenerate.description',
|
||||
blocks: [
|
||||
{ id: 'git.pr.generate.visible', title: 'Visible Prompt' },
|
||||
{ id: 'git.pr.generate.instructions', title: 'Instructions' },
|
||||
{ id: 'git.pr.generate.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'git.pr.generate.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'github.pr.review': {
|
||||
title: 'PR Review',
|
||||
description: 'Prompts used for PR review flow: visible user message + hidden instruction payload.',
|
||||
titleKey: 'settings.magicPrompts.page.group.githubPrReview.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.githubPrReview.description',
|
||||
blocks: [
|
||||
{ id: 'github.pr.review.visible', title: 'Visible Prompt' },
|
||||
{ id: 'github.pr.review.instructions', title: 'Instructions' },
|
||||
{ id: 'github.pr.review.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'github.pr.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'github.issue.review': {
|
||||
title: 'Issue Review',
|
||||
description: 'Prompts used for issue review flow: visible user message + hidden instruction payload.',
|
||||
titleKey: 'settings.magicPrompts.page.group.githubIssueReview.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.githubIssueReview.description',
|
||||
blocks: [
|
||||
{ id: 'github.issue.review.visible', title: 'Visible Prompt' },
|
||||
{ id: 'github.issue.review.instructions', title: 'Instructions' },
|
||||
{ id: 'github.issue.review.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'github.issue.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'github.pr.checks.review': {
|
||||
title: 'PR Failed Checks Review',
|
||||
description: 'Prompts used for PR failed checks analysis.',
|
||||
titleKey: 'settings.magicPrompts.page.group.githubPrFailedChecksReview.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.githubPrFailedChecksReview.description',
|
||||
blocks: [
|
||||
{ id: 'github.pr.checks.review.visible', title: 'Visible Prompt' },
|
||||
{ id: 'github.pr.checks.review.instructions', title: 'Instructions' },
|
||||
{ id: 'github.pr.checks.review.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'github.pr.checks.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'github.pr.comments.review': {
|
||||
title: 'PR Comments Review',
|
||||
description: 'Prompts used for PR comments analysis.',
|
||||
titleKey: 'settings.magicPrompts.page.group.githubPrCommentsReview.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.githubPrCommentsReview.description',
|
||||
blocks: [
|
||||
{ id: 'github.pr.comments.review.visible', title: 'Visible Prompt' },
|
||||
{ id: 'github.pr.comments.review.instructions', title: 'Instructions' },
|
||||
{ id: 'github.pr.comments.review.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'github.pr.comments.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'github.pr.comment.single': {
|
||||
title: 'Single PR Comment Review',
|
||||
description: 'Prompts used for single PR comment analysis.',
|
||||
titleKey: 'settings.magicPrompts.page.group.githubSinglePrCommentReview.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.githubSinglePrCommentReview.description',
|
||||
blocks: [
|
||||
{ id: 'github.pr.comment.single.visible', title: 'Visible Prompt' },
|
||||
{ id: 'github.pr.comment.single.instructions', title: 'Instructions' },
|
||||
{ id: 'github.pr.comment.single.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'github.pr.comment.single.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'git.conflict.resolve': {
|
||||
title: 'Merge/Rebase Conflict Resolution',
|
||||
description: 'Prompts used when resolving merge/rebase conflicts with AI.',
|
||||
titleKey: 'settings.magicPrompts.page.group.gitConflictResolve.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.gitConflictResolve.description',
|
||||
blocks: [
|
||||
{ id: 'git.conflict.resolve.visible', title: 'Visible Prompt' },
|
||||
{ id: 'git.conflict.resolve.instructions', title: 'Instructions' },
|
||||
{ id: 'git.conflict.resolve.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'git.conflict.resolve.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'git.integrate.cherrypick.resolve': {
|
||||
title: 'Cherry-pick Conflict Resolution',
|
||||
description: 'Prompts used when resolving cherry-pick conflicts in integrate flow.',
|
||||
titleKey: 'settings.magicPrompts.page.group.gitCherrypickConflictResolve.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.gitCherrypickConflictResolve.description',
|
||||
blocks: [
|
||||
{ id: 'git.integrate.cherrypick.resolve.visible', title: 'Visible Prompt' },
|
||||
{ id: 'git.integrate.cherrypick.resolve.instructions', title: 'Instructions' },
|
||||
{ id: 'git.integrate.cherrypick.resolve.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'git.integrate.cherrypick.resolve.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'plan.improve': {
|
||||
title: 'Improve Plan',
|
||||
description: 'Hidden prompt used when sending a saved plan into an improve flow.',
|
||||
titleKey: 'settings.magicPrompts.page.group.planImprove.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.planImprove.description',
|
||||
blocks: [
|
||||
{ id: 'plan.improve.visible', title: 'Visible Prompt' },
|
||||
{ id: 'plan.improve.instructions', title: 'Instructions' },
|
||||
{ id: 'plan.improve.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'plan.improve.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'plan.todo': {
|
||||
title: 'Todo Planning',
|
||||
description: 'Hidden prompt used when sending a todo into a new planning session.',
|
||||
titleKey: 'settings.magicPrompts.page.group.planTodo.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.planTodo.description',
|
||||
blocks: [
|
||||
{ id: 'plan.todo.visible', title: 'Visible Prompt' },
|
||||
{ id: 'plan.todo.instructions', title: 'Instructions' },
|
||||
{ id: 'plan.todo.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'plan.todo.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'plan.implement': {
|
||||
title: 'Implement Plan',
|
||||
description: 'Hidden prompt used when sending a saved plan into an implement flow.',
|
||||
titleKey: 'settings.magicPrompts.page.group.planImplement.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.planImplement.description',
|
||||
blocks: [
|
||||
{ id: 'plan.implement.visible', title: 'Visible Prompt' },
|
||||
{ id: 'plan.implement.instructions', title: 'Instructions' },
|
||||
{ id: 'plan.implement.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'plan.implement.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'session.summary': {
|
||||
title: 'Session Summary',
|
||||
description: 'Prompts used by the /summary slash command: visible user message + hidden instructions. Non-destructive — does not compact session history.',
|
||||
titleKey: 'settings.magicPrompts.page.group.sessionSummary.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.sessionSummary.description',
|
||||
blocks: [
|
||||
{ id: 'session.summary.visible', title: 'Visible Prompt' },
|
||||
{ id: 'session.summary.instructions', title: 'Instructions' },
|
||||
{ id: 'session.summary.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'session.summary.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'session.review': {
|
||||
title: 'Workspace Review',
|
||||
description: 'Prompts used by the /review slash command: visible user message + hidden instructions. Reviews current workspace changes for high-signal issues only.',
|
||||
titleKey: 'settings.magicPrompts.page.group.sessionWorkspaceReview.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.sessionWorkspaceReview.description',
|
||||
blocks: [
|
||||
{ id: 'session.review.visible', title: 'Visible Prompt' },
|
||||
{ id: 'session.review.instructions', title: 'Instructions' },
|
||||
{ id: 'session.review.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'session.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -145,6 +146,8 @@ const hasOwn = (input: Record<string, string>, key: string) => Object.prototype.
|
||||
const isVisiblePromptId = (id: MagicPromptId): boolean => id.endsWith('.visible');
|
||||
|
||||
export const MagicPromptsPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const tUnsafe = React.useCallback((key: string) => t(key as Parameters<typeof t>[0]), [t]);
|
||||
const selectedPromptId = useMagicPromptsStore((state) => state.selectedPromptId);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [overrides, setOverrides] = React.useState<Record<string, string>>({});
|
||||
@@ -163,7 +166,7 @@ export const MagicPromptsPage: React.FC = () => {
|
||||
setOverrides(nextOverrides);
|
||||
} catch (error) {
|
||||
console.warn('Failed to load magic prompts:', error);
|
||||
toast.error('Failed to load Magic Prompts');
|
||||
toast.error(t('settings.magicPrompts.page.toast.loadFailed'));
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
@@ -174,7 +177,7 @@ export const MagicPromptsPage: React.FC = () => {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const pageConfig = PROMPT_PAGE_MAP[selectedPromptId] ?? PROMPT_PAGE_MAP['git.commit.generate'];
|
||||
const getBaseline = React.useCallback((id: MagicPromptId) => {
|
||||
@@ -197,7 +200,7 @@ export const MagicPromptsPage: React.FC = () => {
|
||||
const savePrompt = React.useCallback(async (id: MagicPromptId) => {
|
||||
const value = getDraft(id);
|
||||
if (isVisiblePromptId(id) && value.trim().length === 0) {
|
||||
toast.error('Visible prompt cannot be empty');
|
||||
toast.error(t('settings.magicPrompts.page.toast.visiblePromptRequired'));
|
||||
return;
|
||||
}
|
||||
setSavingIds((current) => ({ ...current, [id]: true }));
|
||||
@@ -206,14 +209,14 @@ export const MagicPromptsPage: React.FC = () => {
|
||||
? await resetMagicPromptOverride(id)
|
||||
: await saveMagicPromptOverride(id, value);
|
||||
setOverrides(payload.overrides);
|
||||
toast.success('Magic prompt saved');
|
||||
toast.success(t('settings.magicPrompts.page.toast.saved'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.error('Failed to save magic prompt', { description: message });
|
||||
toast.error(t('settings.magicPrompts.page.toast.saveFailed'), { description: message });
|
||||
} finally {
|
||||
setSavingIds((current) => ({ ...current, [id]: false }));
|
||||
}
|
||||
}, [getDraft]);
|
||||
}, [getDraft, t]);
|
||||
|
||||
const resetPrompt = React.useCallback(async (id: MagicPromptId) => {
|
||||
setResettingIds((current) => ({ ...current, [id]: true }));
|
||||
@@ -224,14 +227,14 @@ export const MagicPromptsPage: React.FC = () => {
|
||||
...current,
|
||||
[id]: getDefaultMagicPromptTemplate(id),
|
||||
}));
|
||||
toast.success('Prompt reset to default');
|
||||
toast.success(t('settings.magicPrompts.page.toast.resetSuccess'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.error('Failed to reset prompt', { description: message });
|
||||
toast.error(t('settings.magicPrompts.page.toast.resetFailed'), { description: message });
|
||||
} finally {
|
||||
setResettingIds((current) => ({ ...current, [id]: false }));
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleResetAll = React.useCallback(async () => {
|
||||
setResettingAll(true);
|
||||
@@ -239,20 +242,20 @@ export const MagicPromptsPage: React.FC = () => {
|
||||
const payload = await resetAllMagicPromptOverrides();
|
||||
setOverrides(payload.overrides);
|
||||
setDrafts({});
|
||||
toast.success('All prompt overrides reset');
|
||||
toast.success(t('settings.magicPrompts.page.toast.resetAllSuccess'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.error('Failed to reset all prompts', { description: message });
|
||||
toast.error(t('settings.magicPrompts.page.toast.resetAllFailed'), { description: message });
|
||||
} finally {
|
||||
setResettingAll(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-6 px-6 flex items-center gap-2 text-muted-foreground">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-current animate-busy-pulse" aria-label="Loading" />
|
||||
<span className="typography-ui">Loading Magic Prompts...</span>
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-current animate-busy-pulse" aria-label={t('settings.magicPrompts.page.loading.aria')} />
|
||||
<span className="typography-ui">{t('settings.magicPrompts.page.loading.text')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -263,13 +266,13 @@ export const MagicPromptsPage: React.FC = () => {
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground">{pageConfig.title}</h2>
|
||||
<h2 className="typography-ui-header font-semibold text-foreground">{tUnsafe(pageConfig.titleKey)}</h2>
|
||||
<Tooltip delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
{pageConfig.description}
|
||||
{tUnsafe(pageConfig.descriptionKey)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -282,7 +285,7 @@ export const MagicPromptsPage: React.FC = () => {
|
||||
}}
|
||||
disabled={resettingAll || Object.keys(overrides).length === 0}
|
||||
>
|
||||
{resettingAll ? 'Resetting...' : 'Reset All Overrides'}
|
||||
{resettingAll ? t('settings.magicPrompts.page.actions.resetting') : t('settings.magicPrompts.page.actions.resetAllOverrides')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -300,7 +303,7 @@ export const MagicPromptsPage: React.FC = () => {
|
||||
<section key={block.id} className={index > 0 ? 'space-y-3 pt-5 border-t border-border' : 'space-y-3'}>
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="typography-ui-label text-foreground">{block.title}</h3>
|
||||
<h3 className="typography-ui-label text-foreground">{tUnsafe(block.titleKey)}</h3>
|
||||
<Tooltip delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
@@ -312,7 +315,8 @@ export const MagicPromptsPage: React.FC = () => {
|
||||
</div>
|
||||
{definition.placeholders && definition.placeholders.length > 0 && (
|
||||
<div className="typography-micro text-muted-foreground">
|
||||
Placeholders: {definition.placeholders.map((item) => `{{${item.key}}}`).join(', ')}
|
||||
{t('settings.magicPrompts.page.placeholdersLabel')}{' '}
|
||||
{definition.placeholders.map((item) => `{{${item.key}}}`).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -323,12 +327,16 @@ export const MagicPromptsPage: React.FC = () => {
|
||||
className="min-h-[220px] font-mono text-sm"
|
||||
/>
|
||||
{isInvalidEmptyVisiblePrompt && (
|
||||
<div className="typography-micro text-[var(--status-error)]">Visible prompt cannot be empty.</div>
|
||||
<div className="typography-micro text-[var(--status-error)]">{t('settings.magicPrompts.page.validation.visiblePromptRequired')}</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
{isDirty ? 'Unsaved changes' : isOverridden ? 'Using saved override' : 'Using built-in default'}
|
||||
{isDirty
|
||||
? t('settings.magicPrompts.page.status.unsavedChanges')
|
||||
: isOverridden
|
||||
? t('settings.magicPrompts.page.status.usingSavedOverride')
|
||||
: t('settings.magicPrompts.page.status.usingBuiltinDefault')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
@@ -339,7 +347,7 @@ export const MagicPromptsPage: React.FC = () => {
|
||||
}}
|
||||
disabled={!isOverridden || saving || resetting}
|
||||
>
|
||||
{resetting ? 'Resetting...' : 'Reset to Default'}
|
||||
{resetting ? t('settings.magicPrompts.page.actions.resetting') : t('settings.magicPrompts.page.actions.resetToDefault')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -348,7 +356,7 @@ export const MagicPromptsPage: React.FC = () => {
|
||||
}}
|
||||
disabled={!isDirty || saving || resetting || isInvalidEmptyVisiblePrompt}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
{saving ? t('settings.common.actions.saving') : t('settings.magicPrompts.page.actions.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,49 +2,51 @@ import React from 'react';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { useMagicPromptsStore } from '@/stores/useMagicPromptsStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface MagicPromptsSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
}
|
||||
|
||||
export const MagicPromptsSidebar: React.FC<MagicPromptsSidebarProps> = ({ onItemSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const selectedPromptId = useMagicPromptsStore((state) => state.selectedPromptId);
|
||||
const setSelectedPromptId = useMagicPromptsStore((state) => state.setSelectedPromptId);
|
||||
|
||||
const grouped = React.useMemo(() => {
|
||||
return [
|
||||
{
|
||||
group: 'Git',
|
||||
groupKey: 'settings.magicPrompts.sidebar.group.git',
|
||||
items: [
|
||||
{ id: 'git.commit.generate', title: 'Commit Generation' },
|
||||
{ id: 'git.pr.generate', title: 'PR Generation' },
|
||||
{ id: 'git.conflict.resolve', title: 'Merge/Rebase Conflict Resolution' },
|
||||
{ id: 'git.integrate.cherrypick.resolve', title: 'Cherry-pick Conflict Resolution' },
|
||||
{ id: 'git.commit.generate', titleKey: 'settings.magicPrompts.sidebar.item.gitCommitGenerate' },
|
||||
{ id: 'git.pr.generate', titleKey: 'settings.magicPrompts.sidebar.item.gitPrGenerate' },
|
||||
{ id: 'git.conflict.resolve', titleKey: 'settings.magicPrompts.sidebar.item.gitConflictResolve' },
|
||||
{ id: 'git.integrate.cherrypick.resolve', titleKey: 'settings.magicPrompts.sidebar.item.gitCherrypickConflictResolve' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'GitHub',
|
||||
groupKey: 'settings.magicPrompts.sidebar.group.github',
|
||||
items: [
|
||||
{ id: 'github.pr.review', title: 'PR Review' },
|
||||
{ id: 'github.issue.review', title: 'Issue Review' },
|
||||
{ id: 'github.pr.checks.review', title: 'PR Failed Checks Review' },
|
||||
{ id: 'github.pr.comments.review', title: 'PR Comments Review' },
|
||||
{ id: 'github.pr.comment.single', title: 'Single PR Comment Review' },
|
||||
{ id: 'github.pr.review', titleKey: 'settings.magicPrompts.sidebar.item.githubPrReview' },
|
||||
{ id: 'github.issue.review', titleKey: 'settings.magicPrompts.sidebar.item.githubIssueReview' },
|
||||
{ id: 'github.pr.checks.review', titleKey: 'settings.magicPrompts.sidebar.item.githubPrFailedChecksReview' },
|
||||
{ id: 'github.pr.comments.review', titleKey: 'settings.magicPrompts.sidebar.item.githubPrCommentsReview' },
|
||||
{ id: 'github.pr.comment.single', titleKey: 'settings.magicPrompts.sidebar.item.githubSinglePrCommentReview' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Planning',
|
||||
groupKey: 'settings.magicPrompts.sidebar.group.planning',
|
||||
items: [
|
||||
{ id: 'plan.todo', title: 'Todo Planning' },
|
||||
{ id: 'plan.improve', title: 'Improve Plan' },
|
||||
{ id: 'plan.implement', title: 'Implement Plan' },
|
||||
{ id: 'plan.todo', titleKey: 'settings.magicPrompts.sidebar.item.planTodo' },
|
||||
{ id: 'plan.improve', titleKey: 'settings.magicPrompts.sidebar.item.planImprove' },
|
||||
{ id: 'plan.implement', titleKey: 'settings.magicPrompts.sidebar.item.planImplement' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Session',
|
||||
groupKey: 'settings.magicPrompts.sidebar.group.session',
|
||||
items: [
|
||||
{ id: 'session.summary', title: 'Session Summary' },
|
||||
{ id: 'session.review', title: 'Workspace Review' },
|
||||
{ id: 'session.summary', titleKey: 'settings.magicPrompts.sidebar.item.sessionSummary' },
|
||||
{ id: 'session.review', titleKey: 'settings.magicPrompts.sidebar.item.sessionWorkspaceReview' },
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
@@ -53,14 +55,14 @@ export const MagicPromptsSidebar: React.FC<MagicPromptsSidebarProps> = ({ onItem
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background">
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<h2 className="text-base font-semibold text-foreground">Magic Prompts</h2>
|
||||
<p className="typography-meta mt-1 text-muted-foreground">Select a prompt template to edit.</p>
|
||||
<h2 className="text-base font-semibold text-foreground">{t('settings.magicPrompts.sidebar.title')}</h2>
|
||||
<p className="typography-meta mt-1 text-muted-foreground">{t('settings.magicPrompts.sidebar.description')}</p>
|
||||
</div>
|
||||
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-3 px-3 py-2 overflow-x-hidden">
|
||||
{grouped.map((group) => (
|
||||
<div key={group.group} className="space-y-1">
|
||||
<div className="typography-micro px-1 text-muted-foreground">{group.group}</div>
|
||||
<div key={group.groupKey} className="space-y-1">
|
||||
<div className="typography-micro px-1 text-muted-foreground">{t(group.groupKey)}</div>
|
||||
{group.items.map((item) => {
|
||||
const selected = selectedPromptId === item.id;
|
||||
return (
|
||||
@@ -76,7 +78,7 @@ export const MagicPromptsSidebar: React.FC<MagicPromptsSidebarProps> = ({ onItem
|
||||
selected ? 'bg-interactive-selection text-foreground' : 'text-foreground hover:bg-interactive-hover'
|
||||
)}
|
||||
>
|
||||
<span className="typography-ui-label truncate font-normal">{item.title}</span>
|
||||
<span className="typography-ui-label truncate font-normal">{t(item.titleKey)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface McpSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
@@ -59,6 +60,7 @@ const StatusDot: React.FC<{ tone: StatusTone; enabled: boolean }> = ({ tone, ena
|
||||
};
|
||||
|
||||
export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const bgClass = 'bg-background';
|
||||
|
||||
const { mcpServers, selectedMcpName, setSelectedMcp, setMcpDraft, loadMcpConfigs, deleteMcp } =
|
||||
@@ -143,13 +145,13 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
if (result.ok) {
|
||||
if (result.reloadFailed) {
|
||||
toast.warning(result.message || `MCP server "${deleteTarget.name}" deleted, but OpenCode reload failed`, {
|
||||
description: result.warning || 'Refresh the MCP list if the UI looks stale.',
|
||||
description: result.warning || t('settings.mcp.sidebar.toast.refreshListIfStale'),
|
||||
});
|
||||
} else {
|
||||
toast.success(result.message || `MCP server "${deleteTarget.name}" deleted`);
|
||||
toast.success(result.message || t('settings.mcp.sidebar.toast.serverDeleted', { name: deleteTarget.name }));
|
||||
}
|
||||
} else {
|
||||
toast.error('Failed to delete MCP server');
|
||||
toast.error(t('settings.mcp.sidebar.toast.deleteFailed'));
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
setIsDeleting(false);
|
||||
@@ -159,14 +161,14 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold text-foreground">MCP Servers</h2>
|
||||
<h2 className="text-base font-semibold text-foreground">{t('settings.mcp.sidebar.title')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
disabled={isRefreshingStatus}
|
||||
onClick={handleRefresh}
|
||||
aria-label="Refresh MCP status"
|
||||
title="Refresh MCP status"
|
||||
aria-label={t('settings.mcp.sidebar.actions.refreshStatusAria')}
|
||||
title={t('settings.mcp.sidebar.actions.refreshStatusTitle')}
|
||||
>
|
||||
<RiRefreshLine className={cn('h-4 w-4', isRefreshingStatus && 'animate-spin')} />
|
||||
</button>
|
||||
@@ -174,13 +176,13 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
<SettingsProjectSelector className="mb-3" />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
Total {mcpServers.length}
|
||||
{t('settings.mcp.sidebar.total', { count: mcpServers.length })}
|
||||
</span>
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
|
||||
onClick={handleCreateNew}
|
||||
title="Add MCP server"
|
||||
title={t('settings.mcp.sidebar.actions.addServerTitle')}
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -192,15 +194,15 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
{mcpServers.length === 0 ? (
|
||||
<div className="py-12 px-4 text-center text-muted-foreground">
|
||||
<RiPlugLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
||||
<p className="typography-ui-label font-medium">No MCP servers configured</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Use the + button above to add one</p>
|
||||
<p className="typography-ui-label font-medium">{t('settings.mcp.sidebar.empty.title')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.mcp.sidebar.empty.description')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{projectServers.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Project Servers
|
||||
{t('settings.mcp.sidebar.group.projectServers')}
|
||||
</div>
|
||||
{projectServers.map((server) => {
|
||||
const runtimeStatus = mcpStatus[server.name];
|
||||
@@ -231,7 +233,10 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot tone={tone} enabled={server.enabled} />
|
||||
<span className="typography-ui-label font-normal truncate text-foreground">{server.name}</span>
|
||||
<span title={server.type === 'local' ? 'Local server' : 'Remote server'}>
|
||||
<span title={server.type === 'local'
|
||||
? t('settings.mcp.sidebar.serverType.localTitle')
|
||||
: t('settings.mcp.sidebar.serverType.remoteTitle')}
|
||||
>
|
||||
{server.type === 'local' ? (
|
||||
<RiServerLine className="h-3 w-3 text-muted-foreground/60 flex-shrink-0" />
|
||||
) : (
|
||||
@@ -261,7 +266,7 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-px" />
|
||||
Delete
|
||||
{t('settings.common.actions.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -274,7 +279,7 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
{userServers.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
User Servers
|
||||
{t('settings.mcp.sidebar.group.userServers')}
|
||||
</div>
|
||||
{userServers.map((server) => {
|
||||
const runtimeStatus = mcpStatus[server.name];
|
||||
@@ -305,7 +310,10 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot tone={tone} enabled={server.enabled} />
|
||||
<span className="typography-ui-label font-normal truncate text-foreground">{server.name}</span>
|
||||
<span title={server.type === 'local' ? 'Local server' : 'Remote server'}>
|
||||
<span title={server.type === 'local'
|
||||
? t('settings.mcp.sidebar.serverType.localTitle')
|
||||
: t('settings.mcp.sidebar.serverType.remoteTitle')}
|
||||
>
|
||||
{server.type === 'local' ? (
|
||||
<RiServerLine className="h-3 w-3 text-muted-foreground/60 flex-shrink-0" />
|
||||
) : (
|
||||
@@ -335,7 +343,7 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-px" />
|
||||
Delete
|
||||
{t('settings.common.actions.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -353,14 +361,14 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => { if (!open && !isDeleting) setDeleteTarget(null); }}
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete MCP Server</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{deleteTarget?.name}"? This will remove it from{' '}
|
||||
<code className="text-foreground">opencode.json</code>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('settings.mcp.sidebar.deleteDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('settings.mcp.sidebar.deleteDialog.descriptionPrefix', { name: deleteTarget?.name || '' })}{' '}
|
||||
<code className="text-foreground">opencode.json</code>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -368,10 +376,10 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleDelete} disabled={isDeleting}>
|
||||
{isDeleting ? 'Deleting…' : 'Delete'}
|
||||
{isDeleting ? t('settings.mcp.sidebar.actions.deleting') : t('settings.common.actions.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -6,12 +6,14 @@ import { useDeviceInfo } from '@/lib/device';
|
||||
import { toast } from '@/components/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const GITHUB_URL = 'https://github.com/btriapitsyn/openchamber';
|
||||
|
||||
const MIN_CHECKING_DURATION = 800; // ms
|
||||
|
||||
export const AboutSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
|
||||
const [showChecking, setShowChecking] = React.useState(false);
|
||||
const updateStore = useUpdateStore();
|
||||
@@ -32,13 +34,13 @@ export const AboutSettings: React.FC = () => {
|
||||
setShowChecking(false);
|
||||
// Show toast if check completed with no update available
|
||||
if (didInitiateCheck.current && !updateStore.available && !updateStore.error) {
|
||||
toast.success('You are on the latest version');
|
||||
toast.success(t('settings.openchamber.about.toast.latestVersion'));
|
||||
didInitiateCheck.current = false;
|
||||
}
|
||||
}, MIN_CHECKING_DURATION);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [updateStore.checking, showChecking, updateStore.available, updateStore.error]);
|
||||
}, [t, updateStore.checking, showChecking, updateStore.available, updateStore.error]);
|
||||
|
||||
const isChecking = updateStore.checking || showChecking;
|
||||
|
||||
@@ -61,7 +63,7 @@ export const AboutSettings: React.FC = () => {
|
||||
isChecking && 'animate-pulse [animation-duration:1s]'
|
||||
)}
|
||||
>
|
||||
Check updates
|
||||
{t('settings.openchamber.about.actions.checkUpdates')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -71,7 +73,7 @@ export const AboutSettings: React.FC = () => {
|
||||
className="flex items-center gap-1 typography-meta text-[var(--primary-base)] hover:underline"
|
||||
>
|
||||
<RiDownloadLine className="h-3.5 w-3.5" />
|
||||
Update
|
||||
{t('settings.openchamber.about.actions.update')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -135,14 +137,14 @@ export const AboutSettings: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-3 px-1">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||
About OpenChamber
|
||||
{t('settings.openchamber.about.title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden flex flex-col">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 px-4 py-3 border-b border-[var(--surface-subtle)]">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="typography-ui-label text-foreground">Version</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.about.field.version')}</span>
|
||||
<span className="typography-meta text-muted-foreground font-mono">{currentVersion}</span>
|
||||
</div>
|
||||
|
||||
@@ -150,7 +152,7 @@ export const AboutSettings: React.FC = () => {
|
||||
{updateStore.checking && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<RiLoaderLine className="h-4 w-4 animate-spin" />
|
||||
<span className="typography-meta">Checking...</span>
|
||||
<span className="typography-meta">{t('settings.openchamber.about.state.checking')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -160,12 +162,12 @@ export const AboutSettings: React.FC = () => {
|
||||
onClick={() => setUpdateDialogOpen(true)}
|
||||
>
|
||||
<RiDownloadLine className="h-4 w-4 mr-1" />
|
||||
Update to {updateStore.info?.version}
|
||||
{t('settings.openchamber.about.actions.updateToVersion', { version: updateStore.info?.version || '' })}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!updateStore.checking && !updateStore.available && !updateStore.error && (
|
||||
<span className="typography-meta text-muted-foreground">Up to date</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.openchamber.about.state.upToDate')}</span>
|
||||
)}
|
||||
|
||||
<Button size="sm"
|
||||
@@ -173,7 +175,7 @@ export const AboutSettings: React.FC = () => {
|
||||
onClick={() => updateStore.checkForUpdates()}
|
||||
disabled={updateStore.checking}
|
||||
>
|
||||
Check for updates
|
||||
{t('settings.openchamber.about.actions.checkForUpdates')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const getDisplayModel = (
|
||||
storedModel: string | undefined
|
||||
@@ -23,6 +24,7 @@ const getDisplayModel = (
|
||||
};
|
||||
|
||||
export const DefaultsSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const setProvider = useConfigStore((state) => state.setProvider);
|
||||
const setModel = useConfigStore((state) => state.setModel);
|
||||
const setAgent = useConfigStore((state) => state.setAgent);
|
||||
@@ -149,10 +151,10 @@ export const DefaultsSettings: React.FC = () => {
|
||||
|
||||
const formatVariantLabel = React.useCallback((variant: string) => {
|
||||
if (variant === DEFAULT_VARIANT_VALUE) {
|
||||
return 'Default';
|
||||
return t('settings.openchamber.defaults.option.default');
|
||||
}
|
||||
return variant.charAt(0).toUpperCase() + variant.slice(1);
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleVariantChange = React.useCallback(
|
||||
async (variant: string) => {
|
||||
@@ -227,20 +229,21 @@ export const DefaultsSettings: React.FC = () => {
|
||||
<div className="mb-6">
|
||||
<div className="mb-0.5 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Session Defaults</h3>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.defaults.title')}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-0">
|
||||
<div className="mt-0 mb-1 typography-meta text-muted-foreground">
|
||||
New sessions will start with:{' '}
|
||||
{t('settings.openchamber.defaults.summaryPrefix')}
|
||||
{' '}
|
||||
{parsedModel.providerId ? (
|
||||
<span className="text-foreground">
|
||||
{parsedModel.providerId}/{parsedModel.modelId}
|
||||
{supportsVariants ? ` (${defaultVariant ?? 'default'})` : ''}
|
||||
{supportsVariants ? ` (${defaultVariant ?? t('settings.openchamber.defaults.option.defaultLowercase')})` : ''}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-foreground">opencode agent default</span>
|
||||
<span className="text-foreground">{t('settings.openchamber.defaults.summaryOpenCodeDefault')}</span>
|
||||
)}
|
||||
{defaultAgent && (
|
||||
<>
|
||||
@@ -252,7 +255,7 @@ export const DefaultsSettings: React.FC = () => {
|
||||
|
||||
<div className={cn('flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8')}>
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">Default Model</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.defaults.field.defaultModel')}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
|
||||
<ModelSelector providerId={parsedModel.providerId} modelId={parsedModel.modelId} onChange={handleModelChange} />
|
||||
@@ -261,17 +264,17 @@ export const DefaultsSettings: React.FC = () => {
|
||||
|
||||
<div className="flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">Default Thinking</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.defaults.field.defaultThinking')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:w-fit">
|
||||
<Select value={defaultVariant ?? DEFAULT_VARIANT_VALUE} onValueChange={handleVariantChange} disabled={!supportsVariants}>
|
||||
<SelectTrigger className="w-fit min-w-[120px]">
|
||||
<SelectValue placeholder="Thinking">
|
||||
<SelectValue placeholder={t('settings.openchamber.defaults.field.thinkingPlaceholder')}>
|
||||
{formatVariantLabel(defaultVariant ?? DEFAULT_VARIANT_VALUE)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={DEFAULT_VARIANT_VALUE}>Default</SelectItem>
|
||||
<SelectItem value={DEFAULT_VARIANT_VALUE}>{t('settings.openchamber.defaults.option.default')}</SelectItem>
|
||||
{availableVariants.map((variant) => (
|
||||
<SelectItem key={variant} value={variant}>
|
||||
{formatVariantLabel(variant)}
|
||||
@@ -284,7 +287,7 @@ export const DefaultsSettings: React.FC = () => {
|
||||
|
||||
<div className="flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">Default Agent</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.defaults.field.defaultAgent')}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
|
||||
<AgentSelector agentName={defaultAgent || ''} onChange={handleAgentChange} />
|
||||
@@ -304,8 +307,8 @@ export const DefaultsSettings: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={showDeletionDialog} onChange={setShowDeletionDialog} ariaLabel="Show deletion dialog" />
|
||||
<span className="typography-ui-label text-foreground">Show Deletion Dialog</span>
|
||||
<Checkbox checked={showDeletionDialog} onChange={setShowDeletionDialog} ariaLabel={t('settings.openchamber.defaults.field.showDeletionDialogAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.defaults.field.showDeletionDialog')}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -321,8 +324,8 @@ export const DefaultsSettings: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={settingsDefaultFileViewerPreview} onChange={setSettingsDefaultFileViewerPreview} ariaLabel="Open files in preview mode" />
|
||||
<span className="typography-ui-label text-foreground">Open files in preview mode</span>
|
||||
<Checkbox checked={settingsDefaultFileViewerPreview} onChange={setSettingsDefaultFileViewerPreview} ariaLabel={t('settings.openchamber.defaults.field.openFilesPreviewAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.defaults.field.openFilesPreview')}</span>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
@@ -3,8 +3,10 @@ import * as React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { getDesktopLanAddress, isDesktopLocalOriginActive, isDesktopShell, restartDesktopApp } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export const DesktopNetworkSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const isLocalDesktop = isDesktopShell() && isDesktopLocalOriginActive();
|
||||
const [savedValue, setSavedValue] = React.useState(false);
|
||||
const [draftValue, setDraftValue] = React.useState(false);
|
||||
@@ -27,7 +29,7 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load desktop settings');
|
||||
throw new Error(t('settings.openchamber.desktopNetwork.error.loadFailed'));
|
||||
}
|
||||
|
||||
const data = (await response.json().catch(() => null)) as null | { desktopLanAccessEnabled?: unknown };
|
||||
@@ -41,7 +43,7 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
setError(null);
|
||||
} catch (cause) {
|
||||
if (!cancelled) {
|
||||
setError(cause instanceof Error ? cause.message : 'Failed to load desktop settings');
|
||||
setError(cause instanceof Error ? cause.message : t('settings.openchamber.desktopNetwork.error.loadFailed'));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
@@ -53,7 +55,7 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isLocalDesktop]);
|
||||
}, [isLocalDesktop, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isLocalDesktop || !draftValue) {
|
||||
@@ -109,20 +111,20 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save desktop settings');
|
||||
throw new Error(t('settings.openchamber.desktopNetwork.error.saveFailed'));
|
||||
}
|
||||
|
||||
setSavedValue(draftValue);
|
||||
|
||||
const restarted = await restartDesktopApp();
|
||||
if (!restarted) {
|
||||
throw new Error('Saved, but failed to restart app');
|
||||
throw new Error(t('settings.openchamber.desktopNetwork.error.savedRestartFailed'));
|
||||
}
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : 'Failed to save desktop settings');
|
||||
setError(cause instanceof Error ? cause.message : t('settings.openchamber.desktopNetwork.error.saveFailed'));
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [draftValue, isDirty]);
|
||||
}, [draftValue, isDirty, t]);
|
||||
|
||||
if (!isLocalDesktop) {
|
||||
return null;
|
||||
@@ -131,7 +133,7 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Desktop Network Access</h3>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.desktopNetwork.title')}</h3>
|
||||
</div>
|
||||
|
||||
<section className="space-y-2 px-2 pb-2 pt-0">
|
||||
@@ -150,16 +152,16 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
<Checkbox
|
||||
checked={draftValue}
|
||||
onChange={handleToggle}
|
||||
ariaLabel="Allow LAN access to desktop sidecar"
|
||||
ariaLabel={t('settings.openchamber.desktopNetwork.field.allowLanAccessAria')}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="typography-ui-label text-foreground">Let other devices on your local network open this app</div>
|
||||
<div className="typography-ui-label text-foreground">{t('settings.openchamber.desktopNetwork.field.allowLanAccess')}</div>
|
||||
<div className="typography-micro text-muted-foreground/70">
|
||||
Restarts the app so phones, tablets, and other computers on your Wi-Fi can open it.
|
||||
{t('settings.openchamber.desktopNetwork.field.allowLanAccessDescription')}
|
||||
</div>
|
||||
<div className="typography-micro text-[var(--status-warning)]/85">
|
||||
Warning: while enabled, the app is reachable by anyone on the same local network.
|
||||
{t('settings.openchamber.desktopNetwork.field.warning')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,7 +172,9 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
|
||||
{lanUrl ? (
|
||||
<div className="px-2 typography-micro text-muted-foreground/80">
|
||||
{isDirty && !savedValue ? 'After restart, open from another device: ' : 'Open from another device: '}
|
||||
{isDirty && !savedValue
|
||||
? t('settings.openchamber.desktopNetwork.hint.openAfterRestart')
|
||||
: t('settings.openchamber.desktopNetwork.hint.openNow')}
|
||||
<span className="font-mono text-foreground">{lanUrl}</span>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -183,7 +187,7 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
disabled={isLoading || isSaving || !isDirty}
|
||||
className="shrink-0 !font-normal"
|
||||
>
|
||||
{isSaving ? 'Saving…' : 'Save + Restart'}
|
||||
{isSaving ? t('settings.common.actions.saving') : t('settings.openchamber.desktopNetwork.actions.saveAndRestart')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { GitHubAuthStatus } from '@/lib/api/types';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { RiGithubFill, RiInformationLine } from '@remixicon/react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
@@ -33,6 +34,7 @@ type DeviceFlowCompleteResponse =
|
||||
| { connected: false; status?: string; error?: string };
|
||||
|
||||
export const GitHubSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const runtimeGitHub = getRegisteredRuntimeAPIs()?.github;
|
||||
const status = useGitHubAuthStore((state) => state.status);
|
||||
@@ -101,11 +103,11 @@ export const GitHubSettings: React.FC = () => {
|
||||
void openExternal(url);
|
||||
} catch (error) {
|
||||
console.error('Failed to start GitHub connect:', error);
|
||||
toast.error('Failed to start GitHub connect');
|
||||
toast.error(t('settings.github.page.toast.startConnectFailed'));
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}, [openExternal, runtimeGitHub]);
|
||||
}, [openExternal, runtimeGitHub, t]);
|
||||
|
||||
const pollOnce = React.useCallback(async (deviceCode: string) => {
|
||||
if (runtimeGitHub) {
|
||||
@@ -141,7 +143,7 @@ export const GitHubSettings: React.FC = () => {
|
||||
try {
|
||||
const result = await pollOnce(flow.deviceCode);
|
||||
if (result.connected) {
|
||||
toast.success('GitHub connected');
|
||||
toast.success(t('settings.github.page.toast.connected'));
|
||||
setFlow(null);
|
||||
stopPolling();
|
||||
await refreshStatus(runtimeGitHub, { force: true });
|
||||
@@ -153,7 +155,7 @@ export const GitHubSettings: React.FC = () => {
|
||||
}
|
||||
|
||||
if (result.status === 'expired_token' || result.status === 'access_denied') {
|
||||
toast.error(result.error || 'GitHub authorization failed');
|
||||
toast.error(result.error || t('settings.github.page.toast.authorizationFailed'));
|
||||
setFlow(null);
|
||||
stopPolling();
|
||||
}
|
||||
@@ -169,7 +171,7 @@ export const GitHubSettings: React.FC = () => {
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [flow, pollIntervalMs, pollOnce, refreshStatus, runtimeGitHub, stopPolling]);
|
||||
}, [flow, pollIntervalMs, pollOnce, refreshStatus, runtimeGitHub, stopPolling, t]);
|
||||
|
||||
const disconnect = React.useCallback(async () => {
|
||||
setIsBusy(true);
|
||||
@@ -187,15 +189,15 @@ export const GitHubSettings: React.FC = () => {
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
}
|
||||
toast.success('GitHub disconnected');
|
||||
toast.success(t('settings.github.page.toast.disconnected'));
|
||||
await refreshStatus(runtimeGitHub, { force: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect GitHub:', error);
|
||||
toast.error('Failed to disconnect GitHub');
|
||||
toast.error(t('settings.github.page.toast.disconnectFailed'));
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}, [refreshStatus, runtimeGitHub, stopPolling]);
|
||||
}, [refreshStatus, runtimeGitHub, stopPolling, t]);
|
||||
|
||||
const activateAccount = React.useCallback(async (accountId: string) => {
|
||||
if (!accountId) return;
|
||||
@@ -220,14 +222,14 @@ export const GitHubSettings: React.FC = () => {
|
||||
})();
|
||||
|
||||
setStatus(payload);
|
||||
toast.success('GitHub account switched');
|
||||
toast.success(t('settings.github.page.toast.accountSwitched'));
|
||||
} catch (error) {
|
||||
console.error('Failed to switch GitHub account:', error);
|
||||
toast.error('Failed to switch GitHub account');
|
||||
toast.error(t('settings.github.page.toast.accountSwitchFailed'));
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}, [runtimeGitHub, setStatus]);
|
||||
}, [runtimeGitHub, setStatus, t]);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
@@ -247,7 +249,7 @@ export const GitHubSettings: React.FC = () => {
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Connect a GitHub account for in-app PR and issue workflows.
|
||||
{t('settings.github.page.tooltip.connectAccount')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -260,7 +262,7 @@ export const GitHubSettings: React.FC = () => {
|
||||
{user?.avatarUrl ? (
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={user.login ? `${user.login} avatar` : 'GitHub avatar'}
|
||||
alt={user.login ? t('settings.github.page.avatarAlt.withLogin', { login: user.login }) : t('settings.github.page.avatarAlt.fallback')}
|
||||
className="h-10 w-10 shrink-0 rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)] object-cover"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
@@ -275,34 +277,38 @@ export const GitHubSettings: React.FC = () => {
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2 typography-meta text-muted-foreground mt-0.5", isMobile ? "flex-wrap" : "truncate")}>
|
||||
<RiGithubFill className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="font-mono">{user?.login || 'unknown'}</span>
|
||||
<span className="font-mono">{user?.login || t('settings.github.page.label.unknownUser')}</span>
|
||||
{user?.email && <span className="opacity-50">•</span>}
|
||||
{user?.email && <span>{user.email}</span>}
|
||||
</div>
|
||||
{status?.scope && (
|
||||
<div className="typography-micro text-muted-foreground/70 mt-0.5">Scopes: {status.scope}</div>
|
||||
<div className="typography-micro text-muted-foreground/70 mt-0.5">
|
||||
{t('settings.github.page.label.scopes', { value: status.scope })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button size="sm" variant="outline" onClick={disconnect} disabled={isBusy} className={cn("text-[var(--status-error)] hover:text-[var(--status-error)]", isMobile ? "w-full" : undefined)}>
|
||||
Disconnect
|
||||
{t('settings.github.page.actions.disconnect')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-4">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="typography-ui-label text-foreground">Not Connected</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.github.page.status.notConnected')}</span>
|
||||
</div>
|
||||
<Button size="sm" variant="default" onClick={startConnect} disabled={isBusy}>
|
||||
Connect GitHub
|
||||
{t('settings.github.page.actions.connect')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{accounts.length > 1 && (
|
||||
<div className="mt-2 border-t border-[var(--surface-subtle)] pt-2 px-2 pb-1">
|
||||
<div className="typography-micro text-muted-foreground mb-2 px-1">Other Accounts</div>
|
||||
<div className="typography-micro text-muted-foreground mb-2 px-1">
|
||||
{t('settings.github.page.label.otherAccounts')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{accounts.map((account) => {
|
||||
const accountUser = account.user;
|
||||
@@ -316,7 +322,7 @@ export const GitHubSettings: React.FC = () => {
|
||||
{accountUser?.avatarUrl ? (
|
||||
<img
|
||||
src={accountUser.avatarUrl}
|
||||
alt={accountUser.login ? `${accountUser.login} avatar` : 'GitHub avatar'}
|
||||
alt={accountUser.login ? t('settings.github.page.avatarAlt.withLogin', { login: accountUser.login }) : t('settings.github.page.avatarAlt.fallback')}
|
||||
className="h-6 w-6 shrink-0 rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)] object-cover"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
@@ -338,14 +344,16 @@ export const GitHubSettings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
{isCurrent ? (
|
||||
<span className="typography-micro text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-1.5 py-0.5 rounded">Active</span>
|
||||
<span className="typography-micro text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-1.5 py-0.5 rounded">
|
||||
{t('settings.github.page.status.active')}
|
||||
</span>
|
||||
) : (
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => activateAccount(account.id)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
Switch to
|
||||
{t('settings.github.page.actions.switchTo')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -365,7 +373,7 @@ export const GitHubSettings: React.FC = () => {
|
||||
disabled={isBusy}
|
||||
className={cn(isMobile ? 'w-full' : undefined)}
|
||||
>
|
||||
Add Account
|
||||
{t('settings.github.page.actions.addAccount')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -373,9 +381,9 @@ export const GitHubSettings: React.FC = () => {
|
||||
{flow && (
|
||||
<div className="mt-4 rounded-lg bg-[var(--surface-elevated)]/70 p-4 border border-[var(--interactive-border)]">
|
||||
<div className="space-y-1">
|
||||
<h4 className="typography-ui-label text-foreground">Authorize OpenChamber</h4>
|
||||
<h4 className="typography-ui-label text-foreground">{t('settings.github.page.flow.title')}</h4>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
In GitHub, enter the following code to authorize this device:
|
||||
{t('settings.github.page.flow.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 mt-4">
|
||||
@@ -386,19 +394,19 @@ export const GitHubSettings: React.FC = () => {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Open GitHub
|
||||
{t('settings.github.page.actions.openGithub')}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<span className="typography-micro text-muted-foreground animate-pulse">
|
||||
Waiting for approval… (auto-refresh)
|
||||
{t('settings.github.page.flow.waiting')}
|
||||
</span>
|
||||
<Button size="sm" variant="ghost" disabled={isBusy} onClick={() => {
|
||||
stopPolling();
|
||||
setFlow(null);
|
||||
}}>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,10 @@ import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { setFilesViewShowGitignored, useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export const GitSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const settingsGitmojiEnabled = useConfigStore((state) => state.settingsGitmojiEnabled);
|
||||
const setSettingsGitmojiEnabled = useConfigStore((state) => state.setSettingsGitmojiEnabled);
|
||||
const showGitignored = useFilesViewShowGitignored();
|
||||
@@ -15,6 +17,13 @@ export const GitSettings: React.FC = () => {
|
||||
const setGitChangesViewMode = useUIStore((state) => state.setGitChangesViewMode);
|
||||
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const viewOptions = React.useMemo(
|
||||
() => [
|
||||
{ id: 'flat' as const, label: t('settings.openchamber.git.option.flatList') },
|
||||
{ id: 'tree' as const, label: t('settings.openchamber.git.option.treeView') },
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
type GitSettingsPayload = {
|
||||
gitmojiEnabled?: boolean;
|
||||
@@ -108,17 +117,14 @@ export const GitSettings: React.FC = () => {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Git Preferences</h3>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.git.title')}</h3>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-0.5">
|
||||
<div className="pt-1 pb-1">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">Changes View</h4>
|
||||
<div role="radiogroup" aria-label="Git changes view mode" className="mt-0.5 space-y-0">
|
||||
{[
|
||||
{ id: 'flat' as const, label: 'Flat List' },
|
||||
{ id: 'tree' as const, label: 'Tree View' },
|
||||
].map((option) => {
|
||||
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.git.changesViewTitle')}</h4>
|
||||
<div role="radiogroup" aria-label={t('settings.openchamber.git.changesViewAria')} className="mt-0.5 space-y-0">
|
||||
{viewOptions.map((option) => {
|
||||
const selected = gitChangesViewMode === option.id;
|
||||
return (
|
||||
<div
|
||||
@@ -138,7 +144,7 @@ export const GitSettings: React.FC = () => {
|
||||
<Radio
|
||||
checked={selected}
|
||||
onChange={() => { handleGitChangesViewModeChange(option.id); }}
|
||||
ariaLabel={`Git changes view mode: ${option.label}`}
|
||||
ariaLabel={t('settings.openchamber.git.optionAria', { option: option.label })}
|
||||
/>
|
||||
<span className={selected ? 'typography-ui-label font-normal text-foreground' : 'typography-ui-label font-normal text-foreground/50'}>
|
||||
{option.label}
|
||||
@@ -169,9 +175,9 @@ export const GitSettings: React.FC = () => {
|
||||
onChange={(checked) => {
|
||||
void handleGitmojiChange(checked);
|
||||
}}
|
||||
ariaLabel="Enable Gitmoji picker"
|
||||
ariaLabel={t('settings.openchamber.git.enableGitmojiAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Enable Gitmoji Picker</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.git.enableGitmoji')}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -190,9 +196,9 @@ export const GitSettings: React.FC = () => {
|
||||
<Checkbox
|
||||
checked={showGitignored}
|
||||
onChange={setFilesViewShowGitignored}
|
||||
ariaLabel="Display gitignored files"
|
||||
ariaLabel={t('settings.openchamber.git.showGitignoredAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Display Gitignored Files</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.git.showGitignored')}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
UNASSIGNED_SHORTCUT,
|
||||
type ShortcutCombo,
|
||||
} from '@/lib/shortcuts';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const MODIFIER_KEYS = new Set(['shift', 'control', 'alt', 'meta']);
|
||||
|
||||
@@ -45,12 +46,19 @@ const keyboardEventToCombo = (event: React.KeyboardEvent<HTMLInputElement>): Sho
|
||||
};
|
||||
|
||||
export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const tUnsafe = React.useCallback((key: string) => t(key as Parameters<typeof t>[0]), [t]);
|
||||
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
|
||||
const setShortcutOverride = useUIStore((state) => state.setShortcutOverride);
|
||||
const clearShortcutOverride = useUIStore((state) => state.clearShortcutOverride);
|
||||
const resetAllShortcutOverrides = useUIStore((state) => state.resetAllShortcutOverrides);
|
||||
|
||||
const actions = React.useMemo(() => getCustomizableShortcutActions(), []);
|
||||
const actionLabel = React.useCallback((id: string, fallbackLabel: string): string => {
|
||||
const key = `settings.openchamber.keyboardShortcuts.action.${id}.label`;
|
||||
const translated = tUnsafe(key);
|
||||
return translated === key ? fallbackLabel : translated;
|
||||
}, [tUnsafe]);
|
||||
|
||||
const [capturingActionId, setCapturingActionId] = React.useState<string | null>(null);
|
||||
const [draftByAction, setDraftByAction] = React.useState<Record<string, ShortcutCombo>>({});
|
||||
@@ -88,13 +96,13 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
setShortcutOverride(actionId, normalized);
|
||||
setPendingOverwrite(null);
|
||||
setErrorText('');
|
||||
setWarningText(isRiskyBrowserShortcut(normalized) ? 'This shortcut can conflict with browser defaults. It is still saved.' : '');
|
||||
setWarningText(isRiskyBrowserShortcut(normalized) ? t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut') : '');
|
||||
setDraftByAction((current) => {
|
||||
const rest = { ...current };
|
||||
delete rest[actionId];
|
||||
return rest;
|
||||
});
|
||||
}, [findConflict, setShortcutOverride]);
|
||||
}, [findConflict, setShortcutOverride, t]);
|
||||
|
||||
const confirmOverwrite = React.useCallback(() => {
|
||||
if (!pendingOverwrite) {
|
||||
@@ -105,13 +113,13 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
setShortcutOverride(pendingOverwrite.actionId, pendingOverwrite.combo);
|
||||
setPendingOverwrite(null);
|
||||
setErrorText('');
|
||||
setWarningText(isRiskyBrowserShortcut(pendingOverwrite.combo) ? 'This shortcut can conflict with browser defaults. It is still saved.' : '');
|
||||
setWarningText(isRiskyBrowserShortcut(pendingOverwrite.combo) ? t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut') : '');
|
||||
setDraftByAction((current) => {
|
||||
const rest = { ...current };
|
||||
delete rest[pendingOverwrite.actionId];
|
||||
return rest;
|
||||
});
|
||||
}, [pendingOverwrite, setShortcutOverride]);
|
||||
}, [pendingOverwrite, setShortcutOverride, t]);
|
||||
|
||||
const resetOne = React.useCallback((actionId: string) => {
|
||||
clearShortcutOverride(actionId);
|
||||
@@ -129,7 +137,7 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Keyboard Shortcuts</h3>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.keyboardShortcuts.title')}</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -143,14 +151,14 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
setWarningText('');
|
||||
}}
|
||||
>
|
||||
Reset All
|
||||
{t('settings.openchamber.keyboardShortcuts.actions.resetAll')}
|
||||
</Button>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Capture a new key combo, save it, and bindings will update immediately.
|
||||
{t('settings.openchamber.keyboardShortcuts.tooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -161,11 +169,11 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
{pendingOverwrite && (
|
||||
<div className="rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<span className="typography-meta text-foreground">
|
||||
This combo is already used by another shortcut. Overwrite and clear that other mapping?
|
||||
{t('settings.openchamber.keyboardShortcuts.overwritePrompt')}
|
||||
</span>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button type="button" size="xs" className="!font-normal" onClick={confirmOverwrite}>Overwrite</Button>
|
||||
<Button type="button" size="xs" className="!font-normal" variant="ghost" onClick={() => setPendingOverwrite(null)}>Cancel</Button>
|
||||
<Button type="button" size="xs" className="!font-normal" onClick={confirmOverwrite}>{t('settings.openchamber.keyboardShortcuts.actions.overwrite')}</Button>
|
||||
<Button type="button" size="xs" className="!font-normal" variant="ghost" onClick={() => setPendingOverwrite(null)}>{t('settings.common.actions.cancel')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -192,12 +200,12 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
return (
|
||||
<div key={action.id} className={cn("flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8", index > 0 && "border-t border-[var(--surface-subtle)]")}>
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">{action.label}</span>
|
||||
<span className="typography-ui-label text-foreground">{actionLabel(action.id, action.label)}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
|
||||
<Input
|
||||
readOnly
|
||||
value={capturingActionId === action.id ? 'Press keys...' : formatShortcutForDisplay(displayCombo)}
|
||||
value={capturingActionId === action.id ? t('settings.openchamber.keyboardShortcuts.field.pressKeys') : formatShortcutForDisplay(displayCombo)}
|
||||
onFocus={() => {
|
||||
setCapturingActionId(action.id);
|
||||
setErrorText('');
|
||||
@@ -239,17 +247,17 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
onClick={() => {
|
||||
const next = draftByAction[action.id];
|
||||
if (!next) {
|
||||
setErrorText('Capture a shortcut first.');
|
||||
setErrorText(t('settings.openchamber.keyboardShortcuts.error.captureFirst'));
|
||||
return;
|
||||
}
|
||||
saveCombo(action.id, next);
|
||||
}}
|
||||
disabled={!hasDraft}
|
||||
>
|
||||
Save
|
||||
{t('settings.common.actions.saveChanges')}
|
||||
</Button>
|
||||
<Button type="button" size="xs" className="!font-normal" variant="ghost" onClick={() => resetOne(action.id)}>
|
||||
Reset
|
||||
{t('settings.common.actions.reset')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,13 +14,33 @@ import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const DEFAULT_NOTIFICATION_TEMPLATES = {
|
||||
completion: { title: '{agent_name} is ready', message: '{model_name} completed the task' },
|
||||
error: { title: 'Tool error', message: '{last_message}' },
|
||||
question: { title: 'Input needed', message: '{last_message}' },
|
||||
subtask: { title: '{agent_name} is ready', message: '{model_name} completed the task' },
|
||||
completion: {
|
||||
titleKey: 'settings.notifications.page.template.defaults.completion.title',
|
||||
messageKey: 'settings.notifications.page.template.defaults.completion.message',
|
||||
},
|
||||
error: {
|
||||
titleKey: 'settings.notifications.page.template.defaults.error.title',
|
||||
messageKey: 'settings.notifications.page.template.defaults.error.message',
|
||||
},
|
||||
question: {
|
||||
titleKey: 'settings.notifications.page.template.defaults.question.title',
|
||||
messageKey: 'settings.notifications.page.template.defaults.question.message',
|
||||
},
|
||||
subtask: {
|
||||
titleKey: 'settings.notifications.page.template.defaults.subtask.title',
|
||||
messageKey: 'settings.notifications.page.template.defaults.subtask.message',
|
||||
},
|
||||
} as const;
|
||||
type NotificationTemplateEvent = keyof typeof DEFAULT_NOTIFICATION_TEMPLATES;
|
||||
const TEMPLATE_EVENT_LABEL_KEYS = {
|
||||
completion: 'settings.notifications.page.template.event.completion',
|
||||
subtask: 'settings.notifications.page.template.event.subtask',
|
||||
error: 'settings.notifications.page.template.event.error',
|
||||
question: 'settings.notifications.page.template.event.question',
|
||||
} as const satisfies Record<NotificationTemplateEvent, string>;
|
||||
|
||||
const UTILITY_PROVIDER_ID = 'zen';
|
||||
const UTILITY_PREFERRED_MODEL_ID = 'big-pickle';
|
||||
@@ -31,6 +51,7 @@ const DEFAULT_SUMMARY_LENGTH = 100;
|
||||
const DEFAULT_MAX_LAST_MESSAGE_LENGTH = 250;
|
||||
|
||||
export const NotificationSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const isDesktop = React.useMemo(() => isDesktopShell(), []);
|
||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
@@ -213,13 +234,13 @@ export const NotificationSettings: React.FC = () => {
|
||||
if (permission === 'granted') {
|
||||
setNativeNotificationsEnabled(true);
|
||||
} else {
|
||||
toast.error('Notification permission denied', {
|
||||
description: 'Please enable notifications in your browser settings.',
|
||||
toast.error(t('settings.notifications.page.toast.permissionDenied.title'), {
|
||||
description: t('settings.notifications.page.toast.permissionDenied.description'),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to request notification permission:', error);
|
||||
toast.error('Failed to request notification permission');
|
||||
toast.error(t('settings.notifications.page.toast.requestPermissionFailed'));
|
||||
}
|
||||
} else if (checked && notificationPermission === 'granted') {
|
||||
setNativeNotificationsEnabled(true);
|
||||
@@ -388,37 +409,37 @@ export const NotificationSettings: React.FC = () => {
|
||||
const handleTestNotification = async () => {
|
||||
const apis = getRegisteredRuntimeAPIs();
|
||||
if (!apis?.notifications) {
|
||||
toast.error('Notifications API not available');
|
||||
toast.error(t('settings.notifications.page.toast.notificationsApiUnavailable'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await apis.notifications.notifyAgentCompletion({
|
||||
title: 'Test Notification',
|
||||
body: 'This is a test notification from OpenChamber.',
|
||||
title: t('settings.notifications.page.testNotification.title'),
|
||||
body: t('settings.notifications.page.testNotification.body'),
|
||||
tag: 'openchamber-test',
|
||||
});
|
||||
|
||||
if (success) {
|
||||
toast.success('Test notification sent successfully');
|
||||
toast.success(t('settings.notifications.page.toast.testNotificationSent'));
|
||||
} else {
|
||||
toast.error('Failed to send test notification');
|
||||
toast.error(t('settings.notifications.page.toast.testNotificationFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Test notification failed:', error);
|
||||
toast.error('Failed to send test notification');
|
||||
toast.error(t('settings.notifications.page.toast.testNotificationFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnableBackgroundNotifications = async () => {
|
||||
if (!pushSupported) {
|
||||
toast.error('Push notifications not supported');
|
||||
toast.error(t('settings.notifications.page.toast.pushUnsupported'));
|
||||
return;
|
||||
}
|
||||
|
||||
const apis = getRegisteredRuntimeAPIs();
|
||||
if (!apis?.push) {
|
||||
toast.error('Push API not available');
|
||||
toast.error(t('settings.notifications.page.toast.pushApiUnavailable'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -428,23 +449,23 @@ export const NotificationSettings: React.FC = () => {
|
||||
const permission = await Notification.requestPermission();
|
||||
setNotificationPermission(permission);
|
||||
if (permission !== 'granted') {
|
||||
toast.error('Notification permission denied', {
|
||||
description: 'Enable notifications in your browser settings.',
|
||||
toast.error(t('settings.notifications.page.toast.permissionDenied.title'), {
|
||||
description: t('settings.notifications.page.toast.permissionDenied.enableInBrowser'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof Notification !== 'undefined' && Notification.permission !== 'granted') {
|
||||
toast.error('Notification permission denied', {
|
||||
description: 'Enable notifications in your browser settings.',
|
||||
toast.error(t('settings.notifications.page.toast.permissionDenied.title'), {
|
||||
description: t('settings.notifications.page.toast.permissionDenied.enableInBrowser'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const key = await apis.push.getVapidPublicKey();
|
||||
if (!key?.publicKey) {
|
||||
toast.error('Failed to load push key');
|
||||
toast.error(t('settings.notifications.page.toast.pushKeyLoadFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -486,16 +507,16 @@ export const NotificationSettings: React.FC = () => {
|
||||
);
|
||||
|
||||
if (!ok?.ok) {
|
||||
toast.error('Failed to enable background notifications');
|
||||
toast.error(t('settings.notifications.page.toast.enableBackgroundFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
setPushSubscribed(true);
|
||||
toast.success('Background notifications enabled');
|
||||
toast.success(t('settings.notifications.page.toast.backgroundEnabled'));
|
||||
} catch (error) {
|
||||
console.error('[Push] Enable failed:', error);
|
||||
const formatted = formatUnknownError(error);
|
||||
toast.error('Failed to enable background notifications', {
|
||||
toast.error(t('settings.notifications.page.toast.enableBackgroundFailed'), {
|
||||
description: formatted.summary,
|
||||
});
|
||||
} finally {
|
||||
@@ -511,7 +532,7 @@ export const NotificationSettings: React.FC = () => {
|
||||
|
||||
const apis = getRegisteredRuntimeAPIs();
|
||||
if (!apis?.push) {
|
||||
toast.error('Push API not available');
|
||||
toast.error(t('settings.notifications.page.toast.pushApiUnavailable'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -528,7 +549,7 @@ export const NotificationSettings: React.FC = () => {
|
||||
await subscription.unsubscribe();
|
||||
await apis.push.unsubscribe({ endpoint });
|
||||
setPushSubscribed(false);
|
||||
toast.success('Background notifications disabled');
|
||||
toast.success(t('settings.notifications.page.toast.backgroundDisabled'));
|
||||
} finally {
|
||||
setPushBusy(false);
|
||||
}
|
||||
@@ -541,7 +562,7 @@ export const NotificationSettings: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Notification Delivery
|
||||
{t('settings.notifications.page.delivery.title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -566,9 +587,9 @@ export const NotificationSettings: React.FC = () => {
|
||||
onChange={(checked) => {
|
||||
void handleToggleChange(checked);
|
||||
}}
|
||||
ariaLabel="Enable notifications"
|
||||
ariaLabel={t('settings.notifications.page.delivery.enableAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Enable Notifications</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.delivery.enableLabel')}</span>
|
||||
</div>
|
||||
|
||||
{nativeNotificationsEnabled && canShowNotifications && (
|
||||
@@ -589,9 +610,9 @@ export const NotificationSettings: React.FC = () => {
|
||||
<Checkbox
|
||||
checked={notificationMode === 'always'}
|
||||
onChange={(checked) => setNotificationMode(checked ? 'always' : 'hidden-only')}
|
||||
ariaLabel="Notify while app is focused"
|
||||
ariaLabel={t('settings.notifications.page.delivery.focusedAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Notify While App is Focused</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.delivery.focusedLabel')}</span>
|
||||
</div>
|
||||
|
||||
<div className="py-2">
|
||||
@@ -601,7 +622,7 @@ export const NotificationSettings: React.FC = () => {
|
||||
size="sm"
|
||||
onClick={() => void handleTestNotification()}
|
||||
>
|
||||
Send test notification
|
||||
{t('settings.notifications.page.delivery.testAction')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -611,16 +632,16 @@ export const NotificationSettings: React.FC = () => {
|
||||
{isBrowser && (
|
||||
<div className="mt-1 px-2">
|
||||
<p className="typography-meta text-muted-foreground/70">
|
||||
Your browser may ask for permission the first time.
|
||||
{t('settings.notifications.page.delivery.browserPermissionHint')}
|
||||
</p>
|
||||
{notificationPermission === 'denied' && (
|
||||
<p className="typography-meta text-[var(--status-error)] mt-1">
|
||||
Notification permission denied. Enable it in your browser settings.
|
||||
{t('settings.notifications.page.delivery.permissionDenied')}
|
||||
</p>
|
||||
)}
|
||||
{notificationPermission === 'granted' && !nativeNotificationsEnabled && (
|
||||
<p className="typography-meta text-muted-foreground/70 mt-1">
|
||||
Permission granted, but notifications are disabled.
|
||||
{t('settings.notifications.page.delivery.permissionGrantedButDisabled')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -628,7 +649,7 @@ export const NotificationSettings: React.FC = () => {
|
||||
{isVSCode && (
|
||||
<div className="mt-1 px-2">
|
||||
<p className="typography-meta text-muted-foreground/70">
|
||||
When enabled, notifications are delivered through VS Code native notifications.
|
||||
{t('settings.notifications.page.delivery.vscodeHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -640,7 +661,7 @@ export const NotificationSettings: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Notification Events
|
||||
{t('settings.notifications.page.events.title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -658,8 +679,8 @@ export const NotificationSettings: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={notifyOnCompletion} onChange={setNotifyOnCompletion} ariaLabel="Agent completion" />
|
||||
<span className="typography-ui-label text-foreground">Agent Completion</span>
|
||||
<Checkbox checked={notifyOnCompletion} onChange={setNotifyOnCompletion} ariaLabel={t('settings.notifications.page.events.completionAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.events.completionLabel')}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -675,8 +696,8 @@ export const NotificationSettings: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={notifyOnSubtasks} onChange={setNotifyOnSubtasks} ariaLabel="Subagent completion" />
|
||||
<span className="typography-ui-label text-foreground">Subagent Completion</span>
|
||||
<Checkbox checked={notifyOnSubtasks} onChange={setNotifyOnSubtasks} ariaLabel={t('settings.notifications.page.events.subtaskAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.events.subtaskLabel')}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -692,8 +713,8 @@ export const NotificationSettings: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={notifyOnError} onChange={setNotifyOnError} ariaLabel="Agent errors" />
|
||||
<span className="typography-ui-label text-foreground">Agent Errors</span>
|
||||
<Checkbox checked={notifyOnError} onChange={setNotifyOnError} ariaLabel={t('settings.notifications.page.events.errorAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.events.errorLabel')}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -709,8 +730,8 @@ export const NotificationSettings: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={notifyOnQuestion} onChange={setNotifyOnQuestion} ariaLabel="Agent questions" />
|
||||
<span className="typography-ui-label text-foreground">Agent Questions</span>
|
||||
<Checkbox checked={notifyOnQuestion} onChange={setNotifyOnQuestion} ariaLabel={t('settings.notifications.page.events.questionAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.events.questionLabel')}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -719,36 +740,43 @@ export const NotificationSettings: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Notification Templates
|
||||
{t('settings.notifications.page.template.title')}
|
||||
</h3>
|
||||
<p className="typography-meta text-muted-foreground mt-0.5">
|
||||
Variables: <code className="text-[var(--primary-base)]">{'{project_name}'}</code> <code className="text-[var(--primary-base)]">{'{worktree}'}</code> <code className="text-[var(--primary-base)]">{'{branch}'}</code> <code className="text-[var(--primary-base)]">{'{session_name}'}</code> <code className="text-[var(--primary-base)]">{'{agent_name}'}</code> <code className="text-[var(--primary-base)]">{'{model_name}'}</code> <code className="text-[var(--primary-base)]">{'{last_message}'}</code>
|
||||
{t('settings.notifications.page.template.variablesLabel')}{' '}
|
||||
<code className="text-[var(--primary-base)]">{'{project_name}'}</code>{' '}
|
||||
<code className="text-[var(--primary-base)]">{'{worktree}'}</code>{' '}
|
||||
<code className="text-[var(--primary-base)]">{'{branch}'}</code>{' '}
|
||||
<code className="text-[var(--primary-base)]">{'{session_name}'}</code>{' '}
|
||||
<code className="text-[var(--primary-base)]">{'{agent_name}'}</code>{' '}
|
||||
<code className="text-[var(--primary-base)]">{'{model_name}'}</code>{' '}
|
||||
<code className="text-[var(--primary-base)]">{'{last_message}'}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 md:gap-3">
|
||||
{(['completion', 'subtask', 'error', 'question'] as const).map((event) => (
|
||||
{(['completion', 'subtask', 'error', 'question'] as const).map((event: NotificationTemplateEvent) => (
|
||||
<section key={event} className="p-2">
|
||||
<span className="typography-ui-label text-foreground font-normal capitalize block">
|
||||
{event === 'subtask' ? 'Subagent Completion' : event}
|
||||
{t(TEMPLATE_EVENT_LABEL_KEYS[event])}
|
||||
</span>
|
||||
<div className="mt-1.5 space-y-2">
|
||||
<div>
|
||||
<label className="typography-micro text-muted-foreground block mb-1">Title</label>
|
||||
<label className="typography-micro text-muted-foreground block mb-1">{t('settings.notifications.page.template.field.title')}</label>
|
||||
<Input
|
||||
value={notificationTemplates[event].title}
|
||||
onChange={(e) => updateTemplate(event, 'title', e.target.value)}
|
||||
className="h-7"
|
||||
placeholder={DEFAULT_NOTIFICATION_TEMPLATES[event].title}
|
||||
placeholder={t(DEFAULT_NOTIFICATION_TEMPLATES[event].titleKey)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="typography-micro text-muted-foreground block mb-1">Message</label>
|
||||
<label className="typography-micro text-muted-foreground block mb-1">{t('settings.notifications.page.template.field.message')}</label>
|
||||
<Input
|
||||
value={notificationTemplates[event].message}
|
||||
onChange={(e) => updateTemplate(event, 'message', e.target.value)}
|
||||
className="h-7"
|
||||
placeholder={DEFAULT_NOTIFICATION_TEMPLATES[event].message}
|
||||
placeholder={t(DEFAULT_NOTIFICATION_TEMPLATES[event].messageKey)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -761,7 +789,7 @@ export const NotificationSettings: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
AI Summarization
|
||||
{t('settings.notifications.page.summary.title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -782,26 +810,28 @@ export const NotificationSettings: React.FC = () => {
|
||||
<Checkbox
|
||||
checked={summarizeLastMessage}
|
||||
onChange={setSummarizeLastMessage}
|
||||
ariaLabel="Summarize last message"
|
||||
ariaLabel={t('settings.notifications.page.summary.toggleAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Summarize Last Message</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.summary.toggleLabel')}</span>
|
||||
</div>
|
||||
<div className="pl-6 pb-1">
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
Requires <code className="text-[var(--primary-base)]">{'{last_message}'}</code> in the notification template.
|
||||
{t('settings.notifications.page.summary.requiresTemplateVariable')}
|
||||
{' '}
|
||||
<code className="text-[var(--primary-base)]">{'{last_message}'}</code>.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={cn("flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8")}>
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="typography-ui-label text-foreground">Summarization Model</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.summary.modelLabel')}</span>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Used for notification and voice summaries.
|
||||
{t('settings.notifications.page.summary.modelTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -812,10 +842,10 @@ export const NotificationSettings: React.FC = () => {
|
||||
onValueChange={handleUtilityModelChange}
|
||||
>
|
||||
<SelectTrigger className="w-fit min-w-[220px]">
|
||||
<SelectValue placeholder="Not selected" />
|
||||
<SelectValue placeholder={t('settings.notifications.page.summary.notSelected')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UTILITY_NOT_SELECTED_VALUE}>Not selected</SelectItem>
|
||||
<SelectItem value={UTILITY_NOT_SELECTED_VALUE}>{t('settings.notifications.page.summary.notSelected')}</SelectItem>
|
||||
{utilityModelOptions.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name}
|
||||
@@ -830,8 +860,8 @@ export const NotificationSettings: React.FC = () => {
|
||||
<>
|
||||
<div className="flex items-center gap-8 py-1.5 mt-1 border-t border-[var(--surface-subtle)]">
|
||||
<div className="flex min-w-0 flex-col w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">Threshold</span>
|
||||
<span className="typography-meta text-muted-foreground">Messages longer than this will be summarized</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.summary.thresholdLabel')}</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.notifications.page.summary.thresholdHint')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
<NumberInput
|
||||
@@ -848,8 +878,8 @@ export const NotificationSettings: React.FC = () => {
|
||||
onClick={() => setSummaryThreshold(DEFAULT_SUMMARY_THRESHOLD)}
|
||||
disabled={summaryThreshold === DEFAULT_SUMMARY_THRESHOLD}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Reset threshold"
|
||||
title="Reset"
|
||||
aria-label={t('settings.notifications.page.summary.resetThresholdAria')}
|
||||
title={t('settings.common.actions.reset')}
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -857,8 +887,8 @@ export const NotificationSettings: React.FC = () => {
|
||||
</div>
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
<div className="flex min-w-0 flex-col w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">Length</span>
|
||||
<span className="typography-meta text-muted-foreground">Target character length of the summary</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.summary.lengthLabel')}</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.notifications.page.summary.lengthHint')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
<NumberInput
|
||||
@@ -875,8 +905,8 @@ export const NotificationSettings: React.FC = () => {
|
||||
onClick={() => setSummaryLength(DEFAULT_SUMMARY_LENGTH)}
|
||||
disabled={summaryLength === DEFAULT_SUMMARY_LENGTH}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Reset summary length"
|
||||
title="Reset"
|
||||
aria-label={t('settings.notifications.page.summary.resetLengthAria')}
|
||||
title={t('settings.common.actions.reset')}
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -886,8 +916,8 @@ export const NotificationSettings: React.FC = () => {
|
||||
) : (
|
||||
<div className={cn("py-1.5 mt-1 border-t border-[var(--surface-subtle)]", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
|
||||
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "w-56 shrink-0")}>
|
||||
<span className="typography-ui-label text-foreground">Max Length</span>
|
||||
<span className="typography-meta text-muted-foreground">Truncate {'{last_message}'} to this length</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.summary.maxLengthLabel')}</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.notifications.page.summary.maxLengthHint')}</span>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2", isMobile ? "w-full" : "w-fit")}>
|
||||
<NumberInput
|
||||
@@ -904,8 +934,8 @@ export const NotificationSettings: React.FC = () => {
|
||||
onClick={() => setMaxLastMessageLength(DEFAULT_MAX_LAST_MESSAGE_LENGTH)}
|
||||
disabled={maxLastMessageLength === DEFAULT_MAX_LAST_MESSAGE_LENGTH}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Reset max message length"
|
||||
title="Reset"
|
||||
aria-label={t('settings.notifications.page.summary.resetMaxLengthAria')}
|
||||
title={t('settings.common.actions.reset')}
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -922,7 +952,7 @@ export const NotificationSettings: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Background Push Notifications
|
||||
{t('settings.notifications.page.push.title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -938,19 +968,21 @@ export const NotificationSettings: React.FC = () => {
|
||||
void handleDisableBackgroundNotifications();
|
||||
}
|
||||
}}
|
||||
ariaLabel="Enable push notifications"
|
||||
ariaLabel={t('settings.notifications.page.push.enableAria')}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className={cn("typography-ui-label", !pushSupported ? "text-muted-foreground" : "text-foreground")}>Enable push notifications</span>
|
||||
<span className={cn("typography-ui-label", !pushSupported ? "text-muted-foreground" : "text-foreground")}>
|
||||
{t('settings.notifications.page.push.enableLabel')}
|
||||
</span>
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
{!pushSupported
|
||||
? "Push not supported. Desktop Chrome/Edge and Android support push. iOS requires an installed PWA."
|
||||
: "Receive alerts via your operating system background service"}
|
||||
? t('settings.notifications.page.push.unsupportedHint')
|
||||
: t('settings.notifications.page.push.supportedHint')}
|
||||
</span>
|
||||
</div>
|
||||
{pushBusy && (
|
||||
<div className="pt-0.5 text-muted-foreground">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-current animate-busy-pulse" aria-label="Loading" />
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-current animate-busy-pulse" aria-label={t('settings.notifications.page.push.loadingAria')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { usePwaDetection } from '@/hooks/usePwaDetection';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { useI18n, type Locale } from '@/lib/i18n';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import {
|
||||
setDirectoryShowHidden,
|
||||
@@ -31,66 +32,66 @@ import {
|
||||
|
||||
interface Option<T extends string> {
|
||||
id: T;
|
||||
label: string;
|
||||
description?: string;
|
||||
labelKey: string;
|
||||
descriptionKey?: string;
|
||||
}
|
||||
|
||||
const THEME_MODE_OPTIONS: Array<{ value: ThemeMode; label: string }> = [
|
||||
const THEME_MODE_OPTIONS: Array<{ value: ThemeMode; labelKey: string }> = [
|
||||
{
|
||||
value: 'system',
|
||||
label: 'System',
|
||||
labelKey: 'settings.openchamber.visual.option.themeMode.system',
|
||||
},
|
||||
{
|
||||
value: 'light',
|
||||
label: 'Light',
|
||||
labelKey: 'settings.openchamber.visual.option.themeMode.light',
|
||||
},
|
||||
{
|
||||
value: 'dark',
|
||||
label: 'Dark',
|
||||
labelKey: 'settings.openchamber.visual.option.themeMode.dark',
|
||||
},
|
||||
];
|
||||
|
||||
const DIFF_LAYOUT_OPTIONS: Option<'dynamic' | 'inline' | 'side-by-side'>[] = [
|
||||
{
|
||||
id: 'dynamic',
|
||||
label: 'Dynamic',
|
||||
description: 'New inline, modified side-by-side.',
|
||||
labelKey: 'settings.openchamber.visual.option.diffLayout.dynamic.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.diffLayout.dynamic.description',
|
||||
},
|
||||
{
|
||||
id: 'inline',
|
||||
label: 'Always inline',
|
||||
description: 'Show as a single unified view.',
|
||||
labelKey: 'settings.openchamber.visual.option.diffLayout.inline.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.diffLayout.inline.description',
|
||||
},
|
||||
{
|
||||
id: 'side-by-side',
|
||||
label: 'Always side-by-side',
|
||||
description: 'Compare original and modified files.',
|
||||
labelKey: 'settings.openchamber.visual.option.diffLayout.sideBySide.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.diffLayout.sideBySide.description',
|
||||
},
|
||||
];
|
||||
|
||||
const DIFF_VIEW_MODE_OPTIONS: Option<'single' | 'stacked'>[] = [
|
||||
{
|
||||
id: 'single',
|
||||
label: 'Single file',
|
||||
description: 'Show one file at a time.',
|
||||
labelKey: 'settings.openchamber.visual.option.diffViewMode.single.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.diffViewMode.single.description',
|
||||
},
|
||||
{
|
||||
id: 'stacked',
|
||||
label: 'All files',
|
||||
description: 'Stack all changed files together.',
|
||||
labelKey: 'settings.openchamber.visual.option.diffViewMode.stacked.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.diffViewMode.stacked.description',
|
||||
},
|
||||
];
|
||||
|
||||
const MERMAID_RENDERING_OPTIONS: Option<'svg' | 'ascii'>[] = [
|
||||
{
|
||||
id: 'svg',
|
||||
label: 'SVG',
|
||||
description: 'Render diagrams as scalable graphics.',
|
||||
labelKey: 'settings.openchamber.visual.option.mermaidRendering.svg.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.mermaidRendering.svg.description',
|
||||
},
|
||||
{
|
||||
id: 'ascii',
|
||||
label: 'ASCII',
|
||||
description: 'Render diagrams as text blocks.',
|
||||
labelKey: 'settings.openchamber.visual.option.mermaidRendering.ascii.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.mermaidRendering.ascii.description',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -98,18 +99,18 @@ const DEFAULT_PWA_INSTALL_NAME = 'OpenChamber - AI Coding Assistant';
|
||||
const PWA_ORIENTATION_OPTIONS: Option<'system' | 'portrait' | 'landscape'>[] = [
|
||||
{
|
||||
id: 'system',
|
||||
label: 'Follow system',
|
||||
description: 'Respect the device rotation setting.',
|
||||
labelKey: 'settings.openchamber.visual.option.pwaOrientation.system.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.pwaOrientation.system.description',
|
||||
},
|
||||
{
|
||||
id: 'portrait',
|
||||
label: 'Portrait lock',
|
||||
description: 'Install the app locked to portrait.',
|
||||
labelKey: 'settings.openchamber.visual.option.pwaOrientation.portrait.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.pwaOrientation.portrait.description',
|
||||
},
|
||||
{
|
||||
id: 'landscape',
|
||||
label: 'Landscape lock',
|
||||
description: 'Install the app locked to landscape.',
|
||||
labelKey: 'settings.openchamber.visual.option.pwaOrientation.landscape.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.pwaOrientation.landscape.description',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -126,91 +127,91 @@ const normalizePwaOrientation = (value: unknown): 'system' | 'portrait' | 'lands
|
||||
const USER_MESSAGE_RENDERING_OPTIONS: Option<'markdown' | 'plain'>[] = [
|
||||
{
|
||||
id: 'markdown',
|
||||
label: 'Markdown',
|
||||
description: 'Render user text with markdown formatting.',
|
||||
labelKey: 'settings.openchamber.visual.option.userMessageRendering.markdown.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.userMessageRendering.markdown.description',
|
||||
},
|
||||
{
|
||||
id: 'plain',
|
||||
label: 'Plain text',
|
||||
description: 'Render user text with preserved whitespace and links.',
|
||||
labelKey: 'settings.openchamber.visual.option.userMessageRendering.plain.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.userMessageRendering.plain.description',
|
||||
},
|
||||
];
|
||||
|
||||
const CHAT_RENDER_MODE_OPTIONS: Option<'sorted' | 'live'>[] = [
|
||||
{
|
||||
id: 'sorted',
|
||||
label: 'Sorted',
|
||||
description: 'Render completed assistant messages without live streaming.',
|
||||
labelKey: 'settings.openchamber.visual.option.chatRenderMode.sorted.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.chatRenderMode.sorted.description',
|
||||
},
|
||||
{
|
||||
id: 'live',
|
||||
label: 'Live',
|
||||
description: 'Stream assistant text and tools as they arrive.',
|
||||
labelKey: 'settings.openchamber.visual.option.chatRenderMode.live.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.chatRenderMode.live.description',
|
||||
},
|
||||
];
|
||||
|
||||
const MESSAGE_STREAM_TRANSPORT_OPTIONS: Option<'auto' | 'ws' | 'sse'>[] = [
|
||||
{
|
||||
id: 'auto',
|
||||
label: 'Auto',
|
||||
description: 'Prefer WebSocket and fall back to SSE if needed.',
|
||||
labelKey: 'settings.openchamber.visual.option.messageTransport.auto.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.messageTransport.auto.description',
|
||||
},
|
||||
{
|
||||
id: 'ws',
|
||||
label: 'WebSocket',
|
||||
description: 'Use WebSocket for message streaming.',
|
||||
labelKey: 'settings.openchamber.visual.option.messageTransport.ws.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.messageTransport.ws.description',
|
||||
},
|
||||
{
|
||||
id: 'sse',
|
||||
label: 'SSE',
|
||||
description: 'Use Server-Sent Events for message streaming.',
|
||||
labelKey: 'settings.openchamber.visual.option.messageTransport.sse.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.messageTransport.sse.description',
|
||||
},
|
||||
];
|
||||
|
||||
const ACTIVITY_RENDER_MODE_OPTIONS: Option<'collapsed' | 'summary'>[] = [
|
||||
{
|
||||
id: 'collapsed',
|
||||
label: 'Collapsed',
|
||||
description: 'Keep Activity collapsed by default.',
|
||||
labelKey: 'settings.openchamber.visual.option.activityRenderMode.collapsed.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.activityRenderMode.collapsed.description',
|
||||
},
|
||||
{
|
||||
id: 'summary',
|
||||
label: 'Expanded',
|
||||
description: 'Expand Activity by default.',
|
||||
labelKey: 'settings.openchamber.visual.option.activityRenderMode.summary.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.activityRenderMode.summary.description',
|
||||
},
|
||||
];
|
||||
|
||||
const TIME_FORMAT_OPTIONS: Option<'auto' | '12h' | '24h'>[] = [
|
||||
{
|
||||
id: 'auto',
|
||||
label: 'Auto',
|
||||
description: 'Use system locale preference.',
|
||||
labelKey: 'settings.openchamber.visual.option.timeFormat.auto.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.timeFormat.auto.description',
|
||||
},
|
||||
{
|
||||
id: '24h',
|
||||
label: '24-hour',
|
||||
description: 'Show time as 14:15.',
|
||||
labelKey: 'settings.openchamber.visual.option.timeFormat.24h.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.timeFormat.24h.description',
|
||||
},
|
||||
{
|
||||
id: '12h',
|
||||
label: '12-hour',
|
||||
description: 'Show time as 02:15 PM.',
|
||||
labelKey: 'settings.openchamber.visual.option.timeFormat.12h.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.timeFormat.12h.description',
|
||||
},
|
||||
];
|
||||
|
||||
const WEEK_START_OPTIONS: Option<'auto' | 'monday' | 'sunday'>[] = [
|
||||
{
|
||||
id: 'auto',
|
||||
label: 'Auto',
|
||||
description: 'Use locale week start.',
|
||||
labelKey: 'settings.openchamber.visual.option.weekStart.auto.label',
|
||||
descriptionKey: 'settings.openchamber.visual.option.weekStart.auto.description',
|
||||
},
|
||||
{
|
||||
id: 'monday',
|
||||
label: 'Monday',
|
||||
labelKey: 'settings.openchamber.visual.option.weekStart.monday.label',
|
||||
},
|
||||
{
|
||||
id: 'sunday',
|
||||
label: 'Sunday',
|
||||
labelKey: 'settings.openchamber.visual.option.weekStart.sunday.label',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -226,6 +227,8 @@ interface OpenChamberVisualSettingsProps {
|
||||
}
|
||||
|
||||
export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps> = ({ visibleSettings }) => {
|
||||
const { locale, locales, setLocale, label, t } = useI18n();
|
||||
const tUnsafe = React.useCallback((key: string) => t(key as Parameters<typeof t>[0]), [t]);
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const { browserTab } = usePwaDetection();
|
||||
const directoryShowHidden = useDirectoryShowHidden();
|
||||
@@ -464,6 +467,18 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
const showPwaOrientationSetting = shouldShow('pwaOrientation') && isWebRuntime() && !isDesktopShell() && !isVSCode;
|
||||
const [pwaInstallName, setPwaInstallName] = React.useState('');
|
||||
const [pwaOrientation, setPwaOrientation] = React.useState<'system' | 'portrait' | 'landscape'>('system');
|
||||
const selectedTimeFormatLabel = React.useMemo(() => {
|
||||
const option = TIME_FORMAT_OPTIONS.find((item) => item.id === timeFormatPreference);
|
||||
return tUnsafe(option?.labelKey ?? 'settings.openchamber.visual.option.timeFormat.auto.label');
|
||||
}, [timeFormatPreference, tUnsafe]);
|
||||
const selectedWeekStartLabel = React.useMemo(() => {
|
||||
const option = WEEK_START_OPTIONS.find((item) => item.id === weekStartPreference);
|
||||
return tUnsafe(option?.labelKey ?? 'settings.openchamber.visual.option.weekStart.auto.label');
|
||||
}, [weekStartPreference, tUnsafe]);
|
||||
const selectedPwaOrientationLabel = React.useMemo(() => {
|
||||
const option = PWA_ORIENTATION_OPTIONS.find((item) => item.id === pwaOrientation);
|
||||
return option ? tUnsafe(option.labelKey) : undefined;
|
||||
}, [pwaOrientation, tUnsafe]);
|
||||
|
||||
const applyPwaInstallName = React.useCallback(async (value: string) => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -570,7 +585,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
|
||||
<div className="pb-1.5">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<span className="typography-ui-header font-medium text-foreground">Color Mode</span>
|
||||
<span className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.colorMode')}</span>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{THEME_MODE_OPTIONS.map((option) => (
|
||||
<Button
|
||||
@@ -578,22 +593,41 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
variant="chip"
|
||||
size="xs"
|
||||
aria-pressed={themeMode === option.value}
|
||||
className="!font-normal"
|
||||
onClick={() => setThemeMode(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
className="!font-normal"
|
||||
onClick={() => setThemeMode(option.value)}
|
||||
>
|
||||
{tUnsafe(option.labelKey)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-1 gap-2 py-1.5 md:grid-cols-[14rem_auto] md:gap-x-8 md:gap-y-2">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="typography-ui-label text-foreground shrink-0">{t('settings.appearance.language.label')}</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.appearance.language.description')}</span>
|
||||
</div>
|
||||
<Select value={locale} onValueChange={(value) => setLocale(value as Locale)}>
|
||||
<SelectTrigger aria-label={t('settings.appearance.language.select')} className="w-fit">
|
||||
<SelectValue>{label(locale)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{locales.map((availableLocale) => (
|
||||
<SelectItem key={availableLocale} value={availableLocale}>
|
||||
{label(availableLocale)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-1 gap-2 py-1.5 md:grid-cols-[14rem_auto] md:gap-x-8 md:gap-y-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="typography-ui-label text-foreground shrink-0">Light Theme</span>
|
||||
<span className="typography-ui-label text-foreground shrink-0">{t('settings.openchamber.visual.field.lightTheme')}</span>
|
||||
<Select value={selectedLightTheme?.metadata.id ?? ''} onValueChange={setLightThemePreference}>
|
||||
<SelectTrigger aria-label="Select light theme" className="w-fit">
|
||||
<SelectValue placeholder="Select theme">
|
||||
<SelectTrigger aria-label={t('settings.openchamber.visual.field.selectLightThemeAria')} className="w-fit">
|
||||
<SelectValue placeholder={t('settings.openchamber.visual.field.selectThemePlaceholder')}>
|
||||
{selectedLightTheme
|
||||
? formatThemeLabel(selectedLightTheme.metadata.name, 'light')
|
||||
: undefined}
|
||||
@@ -609,10 +643,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="typography-ui-label text-foreground shrink-0">Dark Theme</span>
|
||||
<span className="typography-ui-label text-foreground shrink-0">{t('settings.openchamber.visual.field.darkTheme')}</span>
|
||||
<Select value={selectedDarkTheme?.metadata.id ?? ''} onValueChange={setDarkThemePreference}>
|
||||
<SelectTrigger aria-label="Select dark theme" className="w-fit">
|
||||
<SelectValue placeholder="Select theme">
|
||||
<SelectTrigger aria-label={t('settings.openchamber.visual.field.selectDarkThemeAria')} className="w-fit">
|
||||
<SelectValue placeholder={t('settings.openchamber.visual.field.selectThemePlaceholder')}>
|
||||
{selectedDarkTheme
|
||||
? formatThemeLabel(selectedDarkTheme.metadata.name, 'dark')
|
||||
: undefined}
|
||||
@@ -633,14 +667,14 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<div className="mt-1 grid grid-cols-1 gap-2 py-1.5 md:grid-cols-[14rem_auto] md:gap-x-8 md:gap-y-2">
|
||||
{shouldShow('timeFormat') && (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="typography-ui-label text-foreground shrink-0">Time Format</span>
|
||||
<span className="typography-ui-label text-foreground shrink-0">{t('settings.openchamber.visual.field.timeFormat')}</span>
|
||||
<Select value={timeFormatPreference} onValueChange={(value: 'auto' | '12h' | '24h') => handleTimeFormatPreferenceChange(value)}>
|
||||
<SelectTrigger aria-label="Select time format" className="w-fit">
|
||||
<SelectValue />
|
||||
<SelectTrigger aria-label={t('settings.openchamber.visual.field.selectTimeFormatAria')} className="w-fit">
|
||||
<SelectValue>{selectedTimeFormatLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_FORMAT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>{option.label}</SelectItem>
|
||||
<SelectItem key={option.id} value={option.id}>{tUnsafe(option.labelKey)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -649,14 +683,14 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
|
||||
{shouldShow('weekStart') && (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="typography-ui-label text-foreground shrink-0">Week Starts On</span>
|
||||
<span className="typography-ui-label text-foreground shrink-0">{t('settings.openchamber.visual.field.weekStartsOn')}</span>
|
||||
<Select value={weekStartPreference} onValueChange={(value: 'auto' | 'monday' | 'sunday') => handleWeekStartPreferenceChange(value)}>
|
||||
<SelectTrigger aria-label="Select week start" className="w-fit">
|
||||
<SelectValue />
|
||||
<SelectTrigger aria-label={t('settings.openchamber.visual.field.selectWeekStartAria')} className="w-fit">
|
||||
<SelectValue>{selectedWeekStartLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WEEK_START_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>{option.label}</SelectItem>
|
||||
<SelectItem key={option.id} value={option.id}>{tUnsafe(option.labelKey)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -685,20 +719,20 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
}}
|
||||
className="inline-flex items-center typography-ui-label font-normal text-foreground underline decoration-[1px] underline-offset-2 hover:text-foreground/80 disabled:cursor-not-allowed disabled:text-muted-foreground/60"
|
||||
>
|
||||
{themesReloading ? 'Reloading themes...' : 'Reload themes'}
|
||||
{themesReloading ? t('settings.openchamber.visual.actions.reloadingThemes') : t('settings.openchamber.visual.actions.reloadThemes')}
|
||||
</button>
|
||||
<Tooltip delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center rounded-md p-1 text-muted-foreground/70 hover:text-foreground"
|
||||
aria-label="Theme import info"
|
||||
aria-label={t('settings.openchamber.visual.field.themeImportInfoAria')}
|
||||
>
|
||||
<RiInformationLine className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8}>
|
||||
Import custom themes from ~/.config/openchamber/themes/
|
||||
{t('settings.openchamber.visual.field.themeImportInfoTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -706,8 +740,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
{showPwaInstallNameSetting && (
|
||||
<div className="py-1.5 space-y-1.5">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="typography-ui-label text-foreground">Install App Name</span>
|
||||
<span className="typography-meta text-muted-foreground">Used by PWA installation process.</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.installAppName')}</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.openchamber.visual.field.installAppNameHint')}</span>
|
||||
</div>
|
||||
<div className="flex w-full max-w-[28rem] items-center gap-2">
|
||||
<Input
|
||||
@@ -726,7 +760,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
}}
|
||||
className="h-7"
|
||||
maxLength={64}
|
||||
aria-label="PWA install app name"
|
||||
aria-label={t('settings.openchamber.visual.field.pwaInstallAppNameAria')}
|
||||
/>
|
||||
<Button size="sm"
|
||||
type="button"
|
||||
@@ -736,8 +770,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
void applyPwaInstallName('');
|
||||
}}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Reset install app name"
|
||||
title="Reset"
|
||||
aria-label={t('settings.openchamber.visual.actions.resetInstallAppNameAria')}
|
||||
title={t('settings.common.actions.reset')}
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -748,8 +782,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
{showPwaOrientationSetting && (
|
||||
<div className="py-1.5 space-y-1.5">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="typography-ui-label text-foreground">Install Orientation</span>
|
||||
<span className="typography-meta text-muted-foreground">Used by the installed web app. Reinstall the PWA after changing this.</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.installOrientation')}</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.openchamber.visual.field.installOrientationHint')}</span>
|
||||
</div>
|
||||
<div className="flex w-full max-w-[18rem] items-center gap-2">
|
||||
<Select
|
||||
@@ -760,13 +794,15 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
void applyPwaOrientation(orientation);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label="PWA install orientation" className="w-full">
|
||||
<SelectValue placeholder="Select orientation" />
|
||||
<SelectTrigger aria-label={t('settings.openchamber.visual.field.pwaInstallOrientationAria')} className="w-full">
|
||||
<SelectValue placeholder={t('settings.openchamber.visual.field.selectOrientationPlaceholder')}>
|
||||
{selectedPwaOrientationLabel}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PWA_ORIENTATION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
{option.label}
|
||||
{tUnsafe(option.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -780,8 +816,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
}}
|
||||
disabled={pwaOrientation === 'system'}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Reset install orientation"
|
||||
title="Reset"
|
||||
aria-label={t('settings.openchamber.visual.actions.resetInstallOrientationAria')}
|
||||
title={t('settings.common.actions.reset')}
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -796,13 +832,13 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
{hasLayoutSettings && (
|
||||
<div className="mb-8 space-y-3">
|
||||
<section className="p-2 space-y-0.5">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">Spacing & Layout</h4>
|
||||
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.spacingAndLayout')}</h4>
|
||||
<div className="pl-2">
|
||||
|
||||
{shouldShow('fontSize') && !isMobile && (
|
||||
<div className="flex items-center gap-8 py-1">
|
||||
<div className="flex min-w-0 flex-col w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">Interface Font Size</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.interfaceFontSize')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
<NumberInput
|
||||
@@ -811,7 +847,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
min={50}
|
||||
max={200}
|
||||
step={5}
|
||||
aria-label="Font size percentage"
|
||||
aria-label={t('settings.openchamber.visual.field.fontSizePercentageAria')}
|
||||
className="w-16"
|
||||
/>
|
||||
<Button size="sm"
|
||||
@@ -820,8 +856,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
onClick={() => setFontSize(100)}
|
||||
disabled={fontSize === 100}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Reset font size"
|
||||
title="Reset"
|
||||
aria-label={t('settings.openchamber.visual.actions.resetFontSizeAria')}
|
||||
title={t('settings.common.actions.reset')}
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -832,7 +868,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
{shouldShow('terminalFontSize') && (
|
||||
<div className={cn("py-1", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
|
||||
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "w-56 shrink-0")}>
|
||||
<span className="typography-ui-label text-foreground">Terminal Font Size</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.terminalFontSize')}</span>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2", isMobile ? "w-full" : "w-fit")}>
|
||||
<NumberInput
|
||||
@@ -849,8 +885,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
onClick={() => setTerminalFontSize(13)}
|
||||
disabled={terminalFontSize === 13}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Reset terminal font size"
|
||||
title="Reset"
|
||||
aria-label={t('settings.openchamber.visual.actions.resetTerminalFontSizeAria')}
|
||||
title={t('settings.common.actions.reset')}
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -861,7 +897,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
{shouldShow('spacing') && (
|
||||
<div className={cn("py-1", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
|
||||
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "w-56 shrink-0")}>
|
||||
<span className="typography-ui-label text-foreground">Spacing Density</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.spacingDensity')}</span>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2", isMobile ? "w-full" : "w-fit")}>
|
||||
<NumberInput
|
||||
@@ -878,8 +914,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
onClick={() => setPadding(100)}
|
||||
disabled={padding === 100}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Reset spacing"
|
||||
title="Reset"
|
||||
aria-label={t('settings.openchamber.visual.actions.resetSpacingAria')}
|
||||
title={t('settings.common.actions.reset')}
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -891,13 +927,13 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<div className={cn("py-1", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
|
||||
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "w-56 shrink-0")}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="typography-ui-label text-foreground">Input Bar Offset</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.inputBarOffset')}</span>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Raise input bar to avoid OS-level screen obstructions like home bars.
|
||||
{t('settings.openchamber.visual.field.inputBarOffsetTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -917,8 +953,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
onClick={() => setInputBarOffset(0)}
|
||||
disabled={inputBarOffset === 0}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Reset input bar offset"
|
||||
title="Reset"
|
||||
aria-label={t('settings.openchamber.visual.actions.resetInputBarOffsetAria')}
|
||||
title={t('settings.common.actions.reset')}
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -936,7 +972,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
{hasNavigationSettings && (
|
||||
<div className="space-y-3">
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">Navigation</h4>
|
||||
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.navigation')}</h4>
|
||||
{shouldShow('terminalQuickKeys') && !isMobile && (
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
||||
@@ -954,16 +990,16 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Checkbox
|
||||
checked={showTerminalQuickKeysOnDesktop}
|
||||
onChange={setShowTerminalQuickKeysOnDesktop}
|
||||
ariaLabel="Terminal quick keys"
|
||||
ariaLabel={t('settings.openchamber.visual.field.terminalQuickKeysAria')}
|
||||
/>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="typography-ui-label text-foreground">Terminal Quick Keys</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.terminalQuickKeys')}</span>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Show Esc, Ctrl, Arrows in terminal view
|
||||
{t('settings.openchamber.visual.field.terminalQuickKeysTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -982,8 +1018,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<div className="grid grid-cols-1 gap-y-2 md:grid-cols-[minmax(0,16rem)_minmax(0,16rem)] md:justify-start md:gap-x-2">
|
||||
{shouldShow('chatRenderMode') && (
|
||||
<section className="p-2 md:col-span-2">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">Chat Render Mode</h4>
|
||||
<div role="radiogroup" aria-label="Chat render mode" className="mt-1 grid w-full max-w-[26rem] grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.chatRenderMode')}</h4>
|
||||
<div role="radiogroup" aria-label={t('settings.openchamber.visual.section.chatRenderModeAria')} className="mt-1 grid w-full max-w-[26rem] grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{CHAT_RENDER_MODE_OPTIONS.map((option) => {
|
||||
const selected = chatRenderMode === option.id;
|
||||
const previewPhase = chatRenderPreviewTick % 12;
|
||||
@@ -1001,7 +1037,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
)}
|
||||
>
|
||||
<span className={cn('typography-ui-label', selected ? 'text-foreground' : 'text-muted-foreground')}>
|
||||
{option.label}
|
||||
{tUnsafe(option.labelKey)}
|
||||
</span>
|
||||
<div className="mt-2 w-full rounded-md border border-border/60 bg-muted/30 p-2">
|
||||
{option.id === 'live' ? (
|
||||
@@ -1066,7 +1102,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
|
||||
{shouldShow('messageTransport') && (
|
||||
<section className="p-2 md:col-span-2">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">Message Stream Transport</h4>
|
||||
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.messageStreamTransport')}</h4>
|
||||
<div className="mt-1 flex max-w-[24rem] flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{MESSAGE_STREAM_TRANSPORT_OPTIONS.map((option) => (
|
||||
@@ -1078,12 +1114,15 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
className="!font-normal"
|
||||
onClick={() => handleMessageStreamTransportChange(option.id)}
|
||||
>
|
||||
{option.label}
|
||||
{tUnsafe(option.labelKey)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
{MESSAGE_STREAM_TRANSPORT_OPTIONS.find((option) => option.id === messageStreamTransport)?.description}
|
||||
{(() => {
|
||||
const option = MESSAGE_STREAM_TRANSPORT_OPTIONS.find((item) => item.id === messageStreamTransport);
|
||||
return option?.descriptionKey ? tUnsafe(option.descriptionKey) : '';
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1091,8 +1130,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
|
||||
{shouldShow('activityRenderMode') && chatRenderMode === 'sorted' && (
|
||||
<section className="p-2 md:col-span-2">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">Activity Default</h4>
|
||||
<div role="radiogroup" aria-label="Activity default mode" className="mt-0.5 space-y-0">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.activityDefault')}</h4>
|
||||
<div role="radiogroup" aria-label={t('settings.openchamber.visual.section.activityDefaultAria')} className="mt-0.5 space-y-0">
|
||||
{ACTIVITY_RENDER_MODE_OPTIONS.map((option) => {
|
||||
const selected = activityRenderMode === option.id;
|
||||
return (
|
||||
@@ -1113,10 +1152,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Radio
|
||||
checked={selected}
|
||||
onChange={() => handleActivityRenderModeChange(option.id)}
|
||||
ariaLabel={`Activity default mode: ${option.label}`}
|
||||
ariaLabel={t('settings.openchamber.visual.field.activityDefaultModeAria', { option: tUnsafe(option.labelKey) })}
|
||||
/>
|
||||
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
|
||||
{option.label}
|
||||
{tUnsafe(option.labelKey)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -1127,7 +1166,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
|
||||
{shouldShow('expandedTools') && (
|
||||
<section className="p-2 md:col-span-2 space-y-0.5">
|
||||
<div className="typography-ui-header font-medium text-foreground py-1.5">Show tools opened by default:</div>
|
||||
<div className="typography-ui-header font-medium text-foreground py-1.5">{t('settings.openchamber.visual.section.showToolsOpenedByDefault')}</div>
|
||||
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-0.5"
|
||||
@@ -1145,9 +1184,9 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Checkbox
|
||||
checked={showExpandedBashTools}
|
||||
onChange={handleShowExpandedBashToolsChange}
|
||||
ariaLabel="Show expanded bash tools"
|
||||
ariaLabel={t('settings.openchamber.visual.field.showExpandedBashToolsAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Bash</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.bash')}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -1166,17 +1205,17 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Checkbox
|
||||
checked={showExpandedEditTools}
|
||||
onChange={handleShowExpandedEditToolsChange}
|
||||
ariaLabel="Show expanded edit tools"
|
||||
ariaLabel={t('settings.openchamber.visual.field.showExpandedEditToolsAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Edit tools</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.editTools')}</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{shouldShow('userMessageRendering') && (
|
||||
<section className="p-2">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">User Message Rendering</h4>
|
||||
<div role="radiogroup" aria-label="User message rendering mode" className="mt-0.5 space-y-0">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.userMessageRendering')}</h4>
|
||||
<div role="radiogroup" aria-label={t('settings.openchamber.visual.section.userMessageRenderingAria')} className="mt-0.5 space-y-0">
|
||||
{USER_MESSAGE_RENDERING_OPTIONS.map((option) => {
|
||||
const selected = normalizeUserMessageRenderingMode(userMessageRenderingMode) === option.id;
|
||||
return (
|
||||
@@ -1197,10 +1236,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Radio
|
||||
checked={selected}
|
||||
onChange={() => handleUserMessageRenderingModeChange(option.id)}
|
||||
ariaLabel={`User message rendering: ${option.label}`}
|
||||
ariaLabel={t('settings.openchamber.visual.field.userMessageRenderingAria', { option: tUnsafe(option.labelKey) })}
|
||||
/>
|
||||
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
|
||||
{option.label}
|
||||
{tUnsafe(option.labelKey)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -1211,8 +1250,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
|
||||
{shouldShow('mermaidRendering') && (
|
||||
<section className="p-2">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">Mermaid Rendering</h4>
|
||||
<div role="radiogroup" aria-label="Mermaid rendering mode" className="mt-0.5 space-y-0">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.mermaidRendering')}</h4>
|
||||
<div role="radiogroup" aria-label={t('settings.openchamber.visual.section.mermaidRenderingAria')} className="mt-0.5 space-y-0">
|
||||
{MERMAID_RENDERING_OPTIONS.map((option) => {
|
||||
const selected = mermaidRenderingMode === option.id;
|
||||
return (
|
||||
@@ -1233,10 +1272,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Radio
|
||||
checked={selected}
|
||||
onChange={() => handleMermaidRenderingModeChange(option.id)}
|
||||
ariaLabel={`Mermaid rendering: ${option.label}`}
|
||||
ariaLabel={t('settings.openchamber.visual.field.mermaidRenderingAria', { option: tUnsafe(option.labelKey) })}
|
||||
/>
|
||||
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
|
||||
{option.label}
|
||||
{tUnsafe(option.labelKey)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -1247,8 +1286,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
|
||||
{shouldShow('diffLayout') && !isVSCode && (
|
||||
<section className="p-2">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">Diff Layout</h4>
|
||||
<div role="radiogroup" aria-label="Diff layout" className="mt-0.5 space-y-0">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.diffLayout')}</h4>
|
||||
<div role="radiogroup" aria-label={t('settings.openchamber.visual.section.diffLayoutAria')} className="mt-0.5 space-y-0">
|
||||
{DIFF_LAYOUT_OPTIONS.map((option) => {
|
||||
const selected = diffLayoutPreference === option.id;
|
||||
return (
|
||||
@@ -1269,10 +1308,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Radio
|
||||
checked={selected}
|
||||
onChange={() => setDiffLayoutPreference(option.id)}
|
||||
ariaLabel={`Diff layout: ${option.label}`}
|
||||
ariaLabel={t('settings.openchamber.visual.field.diffLayoutAria', { option: tUnsafe(option.labelKey) })}
|
||||
/>
|
||||
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
|
||||
{option.label}
|
||||
{tUnsafe(option.labelKey)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -1283,8 +1322,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
|
||||
{shouldShow('diffLayout') && !isVSCode && (
|
||||
<section className="p-2">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">Diff View Mode</h4>
|
||||
<div role="radiogroup" aria-label="Diff view mode" className="mt-0.5 space-y-0">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.diffViewMode')}</h4>
|
||||
<div role="radiogroup" aria-label={t('settings.openchamber.visual.section.diffViewModeAria')} className="mt-0.5 space-y-0">
|
||||
{DIFF_VIEW_MODE_OPTIONS.map((option) => {
|
||||
const selected = diffViewMode === option.id;
|
||||
return (
|
||||
@@ -1305,10 +1344,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Radio
|
||||
checked={selected}
|
||||
onChange={() => setDiffViewMode(option.id)}
|
||||
ariaLabel={`Diff view mode: ${option.label}`}
|
||||
ariaLabel={t('settings.openchamber.visual.field.diffViewModeAria', { option: tUnsafe(option.labelKey) })}
|
||||
/>
|
||||
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
|
||||
{option.label}
|
||||
{tUnsafe(option.labelKey)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -1338,9 +1377,9 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Checkbox
|
||||
checked={showReasoningTraces}
|
||||
onChange={setShowReasoningTraces}
|
||||
ariaLabel="Show reasoning traces"
|
||||
ariaLabel={t('settings.openchamber.visual.field.showReasoningTracesAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Show Reasoning Traces</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.showReasoningTraces')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1361,9 +1400,9 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Checkbox
|
||||
checked={stickyUserHeader}
|
||||
onChange={handleStickyUserHeaderChange}
|
||||
ariaLabel="Sticky user header"
|
||||
ariaLabel={t('settings.openchamber.visual.field.stickyUserHeaderAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Sticky User Header</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.stickyUserHeader')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1384,9 +1423,9 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Checkbox
|
||||
checked={showToolFileIcons}
|
||||
onChange={handleShowToolFileIconsChange}
|
||||
ariaLabel="Show tool file icons"
|
||||
ariaLabel={t('settings.openchamber.visual.field.showToolFileIconsAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Show Tool File Icons</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.showToolFileIcons')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1407,9 +1446,9 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Checkbox
|
||||
checked={showMobileSessionStatusBar}
|
||||
onChange={setShowMobileSessionStatusBar}
|
||||
ariaLabel="Show mobile status bar"
|
||||
ariaLabel={t('settings.openchamber.visual.field.showMobileStatusBarAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Show Mobile Status Bar</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.showMobileStatusBar')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1430,9 +1469,9 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Checkbox
|
||||
checked={directoryShowHidden}
|
||||
onChange={setDirectoryShowHidden}
|
||||
ariaLabel="Show dotfiles"
|
||||
ariaLabel={t('settings.openchamber.visual.field.showDotfilesAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Show Dotfiles</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.showDotfiles')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1453,16 +1492,16 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Checkbox
|
||||
checked={queueModeEnabled}
|
||||
onChange={setQueueMode}
|
||||
ariaLabel="Queue messages by default"
|
||||
ariaLabel={t('settings.openchamber.visual.field.queueMessagesByDefaultAria')}
|
||||
/>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="typography-ui-label text-foreground">Queue Messages by Default</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.queueMessagesByDefault')}</span>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
When enabled, Enter queues messages. Use {getModifierLabel()}+Enter to send.
|
||||
{t('settings.openchamber.visual.field.queueMessagesByDefaultTooltip', { modifier: getModifierLabel() })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -1486,9 +1525,9 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Checkbox
|
||||
checked={persistChatDraft}
|
||||
onChange={setPersistChatDraft}
|
||||
ariaLabel="Persist draft messages"
|
||||
ariaLabel={t('settings.openchamber.visual.field.persistDraftMessagesAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Persist Draft Messages</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.persistDraftMessages')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1509,9 +1548,9 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<Checkbox
|
||||
checked={inputSpellcheckEnabled}
|
||||
onChange={handleInputSpellcheckChange}
|
||||
ariaLabel="Enable spellcheck in text inputs"
|
||||
ariaLabel={t('settings.openchamber.visual.field.enableSpellcheckInTextInputsAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Enable Spellcheck in Text Inputs</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.enableSpellcheckInTextInputs')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1525,12 +1564,12 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
{shouldShow('reportUsage') && (
|
||||
<div className="space-y-3">
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
<h4 className="typography-ui-header font-medium text-foreground mb-2">Privacy</h4>
|
||||
<h4 className="typography-ui-header font-medium text-foreground mb-2">{t('settings.openchamber.visual.section.privacy')}</h4>
|
||||
<div className="flex items-start gap-2 py-1.5">
|
||||
<Checkbox
|
||||
checked={reportUsage}
|
||||
onChange={handleReportUsageChange}
|
||||
ariaLabel="Send anonymous usage reports"
|
||||
ariaLabel={t('settings.openchamber.visual.field.sendAnonymousUsageReportsAria')}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div
|
||||
@@ -1546,10 +1585,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="typography-ui-label text-foreground">Send anonymous usage reports</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.sendAnonymousUsageReports')}</span>
|
||||
</div>
|
||||
<span className="typography-meta text-muted-foreground pointer-events-none">
|
||||
Helps us understand which app versions are actively used so we can prioritize improvements. Only app version, platform, and runtime are collected - no personal data or code.
|
||||
{t('settings.openchamber.visual.field.sendAnonymousUsageReportsHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,10 @@ import { RiFolderLine, RiInformationLine } from '@remixicon/react';
|
||||
import { isDesktopShell, isTauriShell } from '@/lib/desktop';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export const OpenCodeCliSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [value, setValue] = React.useState('');
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
@@ -58,7 +60,7 @@ export const OpenCodeCliSettings: React.FC = () => {
|
||||
|
||||
try {
|
||||
const selected = await tauri.dialog.open({
|
||||
title: 'Select opencode binary',
|
||||
title: t('settings.openchamber.opencodeCli.dialog.selectBinaryTitle'),
|
||||
multiple: false,
|
||||
directory: false,
|
||||
});
|
||||
@@ -68,31 +70,38 @@ export const OpenCodeCliSettings: React.FC = () => {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleSaveAndReload = React.useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updateDesktopSettings({ opencodeBinary: value.trim() });
|
||||
await reloadOpenCodeConfiguration({ message: 'Restarting OpenCode…', mode: 'projects', scopes: ['all'] });
|
||||
await reloadOpenCodeConfiguration({
|
||||
message: t('settings.openchamber.opencodeCli.actions.restartingOpenCode'),
|
||||
mode: 'projects',
|
||||
scopes: ['all'],
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [value]);
|
||||
}, [t, value]);
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
OpenCode CLI
|
||||
{t('settings.openchamber.opencodeCli.title')}
|
||||
</h3>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Optional absolute path to the <code className="font-mono text-xs">opencode</code> binary.
|
||||
{t('settings.openchamber.opencodeCli.tooltipPrefix')}
|
||||
{' '}
|
||||
<code className="font-mono text-xs">opencode</code>
|
||||
{t('settings.openchamber.opencodeCli.tooltipSuffix')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -101,13 +110,13 @@ export const OpenCodeCliSettings: React.FC = () => {
|
||||
<section className="px-2 pb-2 pt-0 space-y-0.5">
|
||||
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-3">
|
||||
<div className="flex min-w-0 flex-col shrink-0">
|
||||
<span className="typography-ui-label text-foreground">OpenCode Binary Path</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.opencodeCli.field.binaryPath')}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2 sm:w-[20rem]">
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="/Users/you/.bun/bin/opencode"
|
||||
placeholder={t('settings.openchamber.opencodeCli.field.binaryPathPlaceholder')}
|
||||
disabled={isLoading || isSaving}
|
||||
className="h-7 min-w-0 flex-1 font-mono text-xs"
|
||||
/>
|
||||
@@ -118,8 +127,8 @@ export const OpenCodeCliSettings: React.FC = () => {
|
||||
onClick={handleBrowse}
|
||||
disabled={isLoading || isSaving || !isDesktopShell() || !isTauriShell()}
|
||||
className="h-7 w-7 p-0"
|
||||
aria-label="Browse for OpenCode binary path"
|
||||
title="Browse"
|
||||
aria-label={t('settings.openchamber.opencodeCli.actions.browseAria')}
|
||||
title={t('settings.openchamber.opencodeCli.actions.browse')}
|
||||
>
|
||||
<RiFolderLine className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -128,7 +137,14 @@ export const OpenCodeCliSettings: React.FC = () => {
|
||||
|
||||
<div className="py-1.5">
|
||||
<div className="typography-micro text-muted-foreground/70">
|
||||
Tip: you can also use <span className="font-mono">OPENCODE_BINARY</span> env var, but this setting persists in <span className="font-mono">~/.config/openchamber/settings.json</span>.
|
||||
{t('settings.openchamber.opencodeCli.tipPrefix')}
|
||||
{' '}
|
||||
<span className="font-mono">OPENCODE_BINARY</span>
|
||||
{' '}
|
||||
{t('settings.openchamber.opencodeCli.tipMiddle')}
|
||||
{' '}
|
||||
<span className="font-mono">~/.config/openchamber/settings.json</span>
|
||||
{'.'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -140,7 +156,7 @@ export const OpenCodeCliSettings: React.FC = () => {
|
||||
disabled={isLoading || isSaving}
|
||||
className="shrink-0 !font-normal"
|
||||
>
|
||||
{isSaving ? 'Saving…' : 'Save + Reload'}
|
||||
{isSaving ? t('settings.common.actions.saving') : t('settings.openchamber.opencodeCli.actions.saveAndReload')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -14,10 +14,11 @@ import {
|
||||
type PasskeyStatus,
|
||||
type StoredPasskey,
|
||||
} from '@/lib/passkeys';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const formatTimestamp = (timestamp: number | null) => {
|
||||
const formatTimestamp = (timestamp: number | null, neverUsedText: string) => {
|
||||
if (!timestamp || !Number.isFinite(timestamp)) {
|
||||
return 'Never used';
|
||||
return neverUsedText;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
@@ -27,6 +28,7 @@ const formatTimestamp = (timestamp: number | null) => {
|
||||
};
|
||||
|
||||
export const PasskeySettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [supportsPasskeys, setSupportsPasskeys] = React.useState(false);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const [isRegistering, setIsRegistering] = React.useState(false);
|
||||
@@ -45,12 +47,12 @@ export const PasskeySettings: React.FC = () => {
|
||||
const nextPasskeys = await fetchStoredPasskeys();
|
||||
setPasskeys(nextPasskeys);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Could not load passkeys.';
|
||||
const message = error instanceof Error ? error.message : t('settings.openchamber.passkeys.toast.loadFailed');
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -93,7 +95,7 @@ export const PasskeySettings: React.FC = () => {
|
||||
|
||||
const handleRegisterPasskey = React.useCallback(async () => {
|
||||
if (!status.enabled) {
|
||||
const message = 'Enable the UI password lock before adding passkeys.';
|
||||
const message = t('settings.openchamber.passkeys.toast.enableUiPasswordFirst');
|
||||
setErrorMessage(message);
|
||||
toast.message(message);
|
||||
return;
|
||||
@@ -118,20 +120,20 @@ export const PasskeySettings: React.FC = () => {
|
||||
await registerCurrentDevicePasskey();
|
||||
setStatus(await fetchPasskeyStatus());
|
||||
await loadPasskeys();
|
||||
toast.success('Passkey added');
|
||||
toast.success(t('settings.openchamber.passkeys.toast.added'));
|
||||
} catch (error) {
|
||||
if (isPasskeyCeremonyAbort(error)) {
|
||||
toast.message('Passkey setup canceled');
|
||||
toast.message(t('settings.openchamber.passkeys.toast.setupCanceled'));
|
||||
return;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : 'Could not add passkey.';
|
||||
const message = error instanceof Error ? error.message : t('settings.openchamber.passkeys.toast.addFailed');
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsRegistering(false);
|
||||
}
|
||||
}, [isRegistering, loadPasskeys, status.enabled, supportState.reason, supportsPasskeys]);
|
||||
}, [isRegistering, loadPasskeys, status.enabled, supportState.reason, supportsPasskeys, t]);
|
||||
|
||||
const handleRevokePasskey = React.useCallback(async (id: string) => {
|
||||
setRevokingId(id);
|
||||
@@ -141,15 +143,15 @@ export const PasskeySettings: React.FC = () => {
|
||||
await revokeStoredPasskey(id);
|
||||
setStatus(await fetchPasskeyStatus());
|
||||
await loadPasskeys();
|
||||
toast.success('Passkey removed');
|
||||
toast.success(t('settings.openchamber.passkeys.toast.removed'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Could not remove passkey.';
|
||||
const message = error instanceof Error ? error.message : t('settings.openchamber.passkeys.toast.removeFailed');
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setRevokingId(null);
|
||||
}
|
||||
}, [loadPasskeys]);
|
||||
}, [loadPasskeys, t]);
|
||||
|
||||
const handleResetAllAuth = React.useCallback(async () => {
|
||||
setIsResetting(true);
|
||||
@@ -159,23 +161,23 @@ export const PasskeySettings: React.FC = () => {
|
||||
await resetAllAuth();
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Could not clear saved authentication.';
|
||||
const message = error instanceof Error ? error.message : t('settings.openchamber.passkeys.toast.clearAuthFailed');
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
setIsResetting(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Passkeys</h3>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.passkeys.title')}</h3>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-2">
|
||||
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">Current device</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.passkeys.field.currentDevice')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:w-fit">
|
||||
<Button
|
||||
@@ -186,7 +188,7 @@ export const PasskeySettings: React.FC = () => {
|
||||
disabled={isLoading || isResetting}
|
||||
className="!font-normal"
|
||||
>
|
||||
{isRegistering ? 'Cancel passkey setup' : 'Add passkey'}
|
||||
{isRegistering ? t('settings.openchamber.passkeys.actions.cancelSetup') : t('settings.openchamber.passkeys.actions.add')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -196,14 +198,14 @@ export const PasskeySettings: React.FC = () => {
|
||||
disabled={isLoading || isRegistering || isResetting}
|
||||
className="!font-normal text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{isResetting ? 'Signing out…' : 'Sign out everywhere'}
|
||||
{isResetting ? t('settings.openchamber.passkeys.actions.signingOut') : t('settings.openchamber.passkeys.actions.signOutEverywhere')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!status.enabled && (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Passkeys are available only when the UI password lock is enabled.
|
||||
{t('settings.openchamber.passkeys.state.uiPasswordRequired')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -214,9 +216,9 @@ export const PasskeySettings: React.FC = () => {
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="typography-meta text-muted-foreground">Loading passkeys…</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.openchamber.passkeys.state.loading')}</p>
|
||||
) : passkeys.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">No passkeys saved for this host yet.</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.openchamber.passkeys.state.noneSaved')}</p>
|
||||
) : (
|
||||
<div className="space-y-1 pt-1">
|
||||
{passkeys.map((passkey) => (
|
||||
@@ -226,7 +228,13 @@ export const PasskeySettings: React.FC = () => {
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||
<span className="typography-meta text-muted-foreground truncate">
|
||||
{passkey.lastUsedAt ? `Last used ${formatTimestamp(passkey.lastUsedAt)}` : `Added ${formatTimestamp(passkey.createdAt)}`}
|
||||
{passkey.lastUsedAt
|
||||
? t('settings.openchamber.passkeys.item.lastUsed', {
|
||||
time: formatTimestamp(passkey.lastUsedAt, t('settings.openchamber.passkeys.time.neverUsed')),
|
||||
})
|
||||
: t('settings.openchamber.passkeys.item.added', {
|
||||
time: formatTimestamp(passkey.createdAt, t('settings.openchamber.passkeys.time.neverUsed')),
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -236,7 +244,7 @@ export const PasskeySettings: React.FC = () => {
|
||||
disabled={revokingId === passkey.id}
|
||||
className="!font-normal text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{revokingId === passkey.id ? 'Removing…' : 'Remove'}
|
||||
{revokingId === passkey.id ? t('settings.openchamber.passkeys.actions.removing') : t('settings.common.actions.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,16 +7,18 @@ import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const MIN_DAYS = 1;
|
||||
const MAX_DAYS = 365;
|
||||
const DEFAULT_RETENTION_DAYS = 30;
|
||||
const RETENTION_ACTION_OPTIONS = [
|
||||
{ value: 'archive', label: 'Archive' },
|
||||
{ value: 'delete', label: 'Delete' },
|
||||
{ value: 'archive', labelKey: 'settings.openchamber.sessionRetention.action.archive' },
|
||||
{ value: 'delete', labelKey: 'settings.openchamber.sessionRetention.action.delete' },
|
||||
] as const;
|
||||
|
||||
export const SessionRetentionSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const autoDeleteEnabled = useUIStore((state) => state.autoDeleteEnabled);
|
||||
const autoDeleteAfterDays = useUIStore((state) => state.autoDeleteAfterDays);
|
||||
const sessionRetentionAction = useUIStore((state) => state.sessionRetentionAction);
|
||||
@@ -29,35 +31,44 @@ export const SessionRetentionSettings: React.FC = () => {
|
||||
|
||||
const handleRunCleanup = React.useCallback(async () => {
|
||||
const result = await runCleanup({ force: true });
|
||||
const verb = result.action === 'archive' ? 'archiving' : 'deletion';
|
||||
const pastTense = result.action === 'archive' ? 'Archived' : 'Deleted';
|
||||
const failureVerb = result.action === 'archive' ? 'archive' : 'delete';
|
||||
|
||||
if (result.completedIds.length === 0 && result.failedIds.length === 0) {
|
||||
toast.message(`No sessions eligible for ${verb}`);
|
||||
toast.message(
|
||||
result.action === 'archive'
|
||||
? t('settings.openchamber.sessionRetention.toast.noneEligibleArchive')
|
||||
: t('settings.openchamber.sessionRetention.toast.noneEligibleDelete')
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (result.completedIds.length > 0) {
|
||||
toast.success(`${pastTense} ${result.completedIds.length} session${result.completedIds.length === 1 ? '' : 's'}`);
|
||||
toast.success(
|
||||
result.action === 'archive'
|
||||
? t('settings.openchamber.sessionRetention.toast.archivedCount', { count: result.completedIds.length })
|
||||
: t('settings.openchamber.sessionRetention.toast.deletedCount', { count: result.completedIds.length })
|
||||
);
|
||||
}
|
||||
if (result.failedIds.length > 0) {
|
||||
toast.error(`Failed to ${failureVerb} ${result.failedIds.length} session${result.failedIds.length === 1 ? '' : 's'}`);
|
||||
toast.error(
|
||||
result.action === 'archive'
|
||||
? t('settings.openchamber.sessionRetention.toast.failedArchiveCount', { count: result.failedIds.length })
|
||||
: t('settings.openchamber.sessionRetention.toast.failedDeleteCount', { count: result.failedIds.length })
|
||||
);
|
||||
}
|
||||
}, [runCleanup]);
|
||||
}, [runCleanup, t]);
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Session Retention
|
||||
{t('settings.openchamber.sessionRetention.title')}
|
||||
</h3>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Automatically archive or delete inactive sessions based on last activity. Keeps the 5 most recent sessions.
|
||||
{t('settings.openchamber.sessionRetention.tooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -80,14 +91,14 @@ export const SessionRetentionSettings: React.FC = () => {
|
||||
<Checkbox
|
||||
checked={autoDeleteEnabled}
|
||||
onChange={setAutoDeleteEnabled}
|
||||
ariaLabel="Enable auto-cleanup"
|
||||
ariaLabel={t('settings.openchamber.sessionRetention.field.enableAutoCleanupAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Enable Auto-Cleanup</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.sessionRetention.field.enableAutoCleanup')}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">Retention Period</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.sessionRetention.field.retentionPeriod')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:w-fit">
|
||||
<NumberInput
|
||||
@@ -96,18 +107,18 @@ export const SessionRetentionSettings: React.FC = () => {
|
||||
min={MIN_DAYS}
|
||||
max={MAX_DAYS}
|
||||
step={1}
|
||||
aria-label="Retention period in days"
|
||||
aria-label={t('settings.openchamber.sessionRetention.field.retentionPeriodAria')}
|
||||
className="w-20 tabular-nums"
|
||||
/>
|
||||
<span className="typography-ui-label text-muted-foreground">days</span>
|
||||
<span className="typography-ui-label text-muted-foreground">{t('settings.openchamber.sessionRetention.field.days')}</span>
|
||||
<Button size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setAutoDeleteAfterDays(DEFAULT_RETENTION_DAYS)}
|
||||
disabled={autoDeleteAfterDays === DEFAULT_RETENTION_DAYS}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Reset retention period"
|
||||
title="Reset"
|
||||
aria-label={t('settings.openchamber.sessionRetention.actions.resetRetentionAria')}
|
||||
title={t('settings.common.actions.reset')}
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -116,7 +127,7 @@ export const SessionRetentionSettings: React.FC = () => {
|
||||
|
||||
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">When sessions expire</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.sessionRetention.field.whenSessionsExpire')}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1 sm:w-fit">
|
||||
{RETENTION_ACTION_OPTIONS.map((option) => (
|
||||
@@ -129,7 +140,7 @@ export const SessionRetentionSettings: React.FC = () => {
|
||||
className="!font-normal"
|
||||
onClick={() => setSessionRetentionAction(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
{t(option.labelKey)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
@@ -139,7 +150,7 @@ export const SessionRetentionSettings: React.FC = () => {
|
||||
<div className="mt-1 px-2 py-1.5 space-y-1">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<p className="typography-meta text-foreground font-medium">Manual Cleanup</p>
|
||||
<p className="typography-meta text-foreground font-medium">{t('settings.openchamber.sessionRetention.manualCleanup.title')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:w-fit">
|
||||
<Button
|
||||
@@ -150,12 +161,14 @@ export const SessionRetentionSettings: React.FC = () => {
|
||||
disabled={isRunning}
|
||||
className="!font-normal"
|
||||
>
|
||||
{isRunning ? 'Cleaning up...' : 'Run cleanup now'}
|
||||
{isRunning ? t('settings.openchamber.sessionRetention.actions.cleaningUp') : t('settings.openchamber.sessionRetention.actions.runCleanupNow')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Eligible for {action === 'archive' ? 'archiving' : 'deletion'} right now: <span className="tabular-nums">{pendingCount}</span>
|
||||
{action === 'archive'
|
||||
? t('settings.openchamber.sessionRetention.manualCleanup.eligibleArchiveNow', { count: pendingCount })
|
||||
: t('settings.openchamber.sessionRetention.manualCleanup.eligibleDeleteNow', { count: pendingCount })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { requestFileAccess } from '@/lib/desktop';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
|
||||
@@ -67,14 +68,26 @@ const SESSION_TTL_OPTIONS: TtlOption[] = [
|
||||
const MANAGED_REMOTE_TUNNEL_DOC_URL = 'https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/get-started/create-remote-tunnel/';
|
||||
const MANAGED_LOCAL_TUNNEL_DOC_URL = 'https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/local-management/configuration-file/';
|
||||
|
||||
const TUNNEL_MODE_OPTIONS: Array<{ value: TunnelMode; label: string; tooltip: string }> = [
|
||||
{ value: 'quick', label: 'Quick', tooltip: 'Quick Tunnel is best effort and Cloudflare does not guarantee uptime.' },
|
||||
{ value: 'managed-remote', label: 'Managed Remote', tooltip: 'Managed Remote uses your Cloudflare account and hostname for long-lived access.' },
|
||||
{ value: 'managed-local', label: 'Managed Local', tooltip: 'Managed Local uses your local cloudflared configuration file.' },
|
||||
const TUNNEL_MODE_OPTIONS: Array<{ value: TunnelMode; labelKey: string; tooltipKey: string }> = [
|
||||
{
|
||||
value: 'quick',
|
||||
labelKey: 'settings.openchamber.tunnel.option.mode.quick.label',
|
||||
tooltipKey: 'settings.openchamber.tunnel.option.mode.quick.tooltip',
|
||||
},
|
||||
{
|
||||
value: 'managed-remote',
|
||||
labelKey: 'settings.openchamber.tunnel.option.mode.managedRemote.label',
|
||||
tooltipKey: 'settings.openchamber.tunnel.option.mode.managedRemote.tooltip',
|
||||
},
|
||||
{
|
||||
value: 'managed-local',
|
||||
labelKey: 'settings.openchamber.tunnel.option.mode.managedLocal.label',
|
||||
tooltipKey: 'settings.openchamber.tunnel.option.mode.managedLocal.tooltip',
|
||||
},
|
||||
];
|
||||
|
||||
const MANAGED_LOCAL_CONFIG_ALLOWED_EXTENSIONS = ['.yml', '.yaml', '.json'];
|
||||
const MANAGED_LOCAL_CONFIG_EXTENSION_ERROR = 'Config file must use .yml, .yaml, or .json extension.';
|
||||
const MANAGED_LOCAL_CONFIG_EXTENSION_ERROR_KEY = 'settings.openchamber.tunnel.error.invalidConfigExtension';
|
||||
|
||||
const hasAllowedManagedLocalConfigExtension = (filePath: string): boolean => {
|
||||
const normalized = filePath.trim().toLowerCase();
|
||||
@@ -258,6 +271,8 @@ const createPresetId = (): string => {
|
||||
};
|
||||
|
||||
export const TunnelSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const tUnsafe = React.useCallback((key: string) => t(key as Parameters<typeof t>[0]), [t]);
|
||||
const [state, setState] = React.useState<TunnelState>('checking');
|
||||
const [tunnelInfo, setTunnelInfo] = React.useState<TunnelInfo | null>(null);
|
||||
const [activeTunnelMode, setActiveTunnelMode] = React.useState<TunnelMode | null>(null);
|
||||
@@ -286,6 +301,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
const [sessionRecords, setSessionRecords] = React.useState<TunnelSessionRecord[]>([]);
|
||||
const [nowTs, setNowTs] = React.useState<number>(() => Date.now());
|
||||
const [localPort, setLocalPort] = React.useState<number | null>(null);
|
||||
const managedLocalConfigExtensionError = t(MANAGED_LOCAL_CONFIG_EXTENSION_ERROR_KEY);
|
||||
const managedLocalConfigFileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const isManagedLocalConfigPathInvalid = React.useMemo(() => {
|
||||
if (!managedLocalConfigPath) {
|
||||
@@ -306,8 +322,10 @@ export const TunnelSettings: React.FC = () => {
|
||||
? formatRemaining(record.expiresAt - nowTs)
|
||||
: (record.inactiveReason === 'expired' || isExpired ? 'expired' : 'inactive');
|
||||
const inactiveLabel = remainingTextForSession === 'expired'
|
||||
? 'Expired'
|
||||
: (record.inactiveReason === 'tunnel-revoked' ? 'Revoked' : 'Inactive');
|
||||
? t('settings.openchamber.tunnel.state.expired')
|
||||
: (record.inactiveReason === 'tunnel-revoked'
|
||||
? t('settings.openchamber.tunnel.state.revoked')
|
||||
: t('settings.openchamber.tunnel.state.inactive'));
|
||||
|
||||
const mode = toUiTunnelMode(record.mode);
|
||||
return {
|
||||
@@ -318,7 +336,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
inactiveLabel,
|
||||
};
|
||||
});
|
||||
}, [nowTs, sessionRecords]);
|
||||
}, [nowTs, sessionRecords, t]);
|
||||
const isConnectLinkLive = React.useMemo(() => {
|
||||
if (!tunnelInfo?.connectUrl) {
|
||||
return false;
|
||||
@@ -444,10 +462,10 @@ export const TunnelSettings: React.FC = () => {
|
||||
} catch {
|
||||
if (!signal.aborted) {
|
||||
setState('error');
|
||||
setErrorMessage('Failed to check tunnel availability');
|
||||
setErrorMessage(t('settings.openchamber.tunnel.toast.checkAvailabilityFailed'));
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -483,7 +501,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!tunnelInfo?.bootstrapExpiresAt) {
|
||||
setRemainingText('No expiry');
|
||||
setRemainingText(t('settings.openchamber.tunnel.state.noExpiry'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -493,7 +511,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
const updateRemaining = () => {
|
||||
const remaining = tunnelInfo.bootstrapExpiresAt ? tunnelInfo.bootstrapExpiresAt - Date.now() : 0;
|
||||
if (remaining <= 0) {
|
||||
setRemainingText('Expired');
|
||||
setRemainingText(t('settings.openchamber.tunnel.state.expired'));
|
||||
} else {
|
||||
setRemainingText(formatRemaining(remaining));
|
||||
}
|
||||
@@ -533,7 +551,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
};
|
||||
}, [tunnelInfo?.bootstrapExpiresAt]);
|
||||
}, [t, tunnelInfo?.bootstrapExpiresAt]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Use requestAnimationFrame for smoother updates without setInterval overhead
|
||||
@@ -637,11 +655,11 @@ export const TunnelSettings: React.FC = () => {
|
||||
setManagedRemoteTunnelPresets(payload.managedRemoteTunnelPresets);
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to save tunnel settings');
|
||||
toast.error(t('settings.openchamber.tunnel.toast.saveSettingsFailed'));
|
||||
} finally {
|
||||
setIsSavingMode(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const saveTtlSettings = React.useCallback(async (nextBootstrapTtlMs: number | null, nextSessionTtlMs: number) => {
|
||||
setIsSavingTtl(true);
|
||||
@@ -651,11 +669,11 @@ export const TunnelSettings: React.FC = () => {
|
||||
tunnelSessionTtlMs: nextSessionTtlMs,
|
||||
});
|
||||
} catch {
|
||||
toast.error('Failed to save tunnel TTL settings');
|
||||
toast.error(t('settings.openchamber.tunnel.toast.saveTtlFailed'));
|
||||
} finally {
|
||||
setIsSavingTtl(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const persistManagedRemoteTunnelToken = React.useCallback(async (payload: {
|
||||
presetId: string;
|
||||
@@ -682,9 +700,9 @@ export const TunnelSettings: React.FC = () => {
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
toast.error('Failed to save managed remote tunnel token');
|
||||
toast.error(t('settings.openchamber.tunnel.toast.saveTokenFailed'));
|
||||
}
|
||||
}, [sessionTokensByPresetId]);
|
||||
}, [sessionTokensByPresetId, t]);
|
||||
|
||||
const handleProviderChange = React.useCallback(async (provider: string) => {
|
||||
setManagedRemoteValidationError(null);
|
||||
@@ -700,7 +718,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
if (result.success && typeof result.path === 'string' && result.path.trim().length > 0) {
|
||||
const nextPath = result.path.trim();
|
||||
if (!hasAllowedManagedLocalConfigExtension(nextPath)) {
|
||||
toast.error(MANAGED_LOCAL_CONFIG_EXTENSION_ERROR);
|
||||
toast.error(managedLocalConfigExtensionError);
|
||||
return;
|
||||
}
|
||||
setManagedLocalConfigPath(nextPath);
|
||||
@@ -709,7 +727,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
}
|
||||
|
||||
managedLocalConfigFileInputRef.current?.click();
|
||||
}, [saveTunnelSettings]);
|
||||
}, [managedLocalConfigExtensionError, saveTunnelSettings]);
|
||||
|
||||
const handleManagedLocalConfigInputChange = React.useCallback((value: string) => {
|
||||
const trimmed = value.trim();
|
||||
@@ -718,11 +736,11 @@ export const TunnelSettings: React.FC = () => {
|
||||
|
||||
const handleManagedLocalConfigInputBlur = React.useCallback(async () => {
|
||||
if (managedLocalConfigPath && !hasAllowedManagedLocalConfigExtension(managedLocalConfigPath)) {
|
||||
toast.error(MANAGED_LOCAL_CONFIG_EXTENSION_ERROR);
|
||||
toast.error(managedLocalConfigExtensionError);
|
||||
return;
|
||||
}
|
||||
await saveTunnelSettings({ managedLocalTunnelConfigPath: managedLocalConfigPath });
|
||||
}, [managedLocalConfigPath, saveTunnelSettings]);
|
||||
}, [managedLocalConfigExtensionError, managedLocalConfigPath, saveTunnelSettings]);
|
||||
|
||||
const handleManagedLocalConfigClear = React.useCallback(async () => {
|
||||
setManagedLocalConfigPath(null);
|
||||
@@ -740,22 +758,22 @@ export const TunnelSettings: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
if (!hasAllowedManagedLocalConfigExtension(fallbackPath)) {
|
||||
toast.error(MANAGED_LOCAL_CONFIG_EXTENSION_ERROR);
|
||||
toast.error(managedLocalConfigExtensionError);
|
||||
return;
|
||||
}
|
||||
|
||||
setManagedLocalConfigPath(fallbackPath);
|
||||
await saveTunnelSettings({ managedLocalTunnelConfigPath: fallbackPath });
|
||||
event.target.value = '';
|
||||
}, [saveTunnelSettings]);
|
||||
}, [managedLocalConfigExtensionError, saveTunnelSettings]);
|
||||
|
||||
const handleStart = React.useCallback(async () => {
|
||||
setErrorMessage(null);
|
||||
setManagedRemoteValidationError(null);
|
||||
|
||||
if (tunnelMode === 'managed-local' && managedLocalConfigPath && !hasAllowedManagedLocalConfigExtension(managedLocalConfigPath)) {
|
||||
setErrorMessage(MANAGED_LOCAL_CONFIG_EXTENSION_ERROR);
|
||||
toast.error(MANAGED_LOCAL_CONFIG_EXTENSION_ERROR);
|
||||
setErrorMessage(managedLocalConfigExtensionError);
|
||||
toast.error(managedLocalConfigExtensionError);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -768,8 +786,8 @@ export const TunnelSettings: React.FC = () => {
|
||||
if (tunnelMode === 'managed-remote') {
|
||||
if (!selectedPreset) {
|
||||
setState('idle');
|
||||
setManagedRemoteValidationError('Select or add a managed remote tunnel first');
|
||||
toast.error('Select or add a managed remote tunnel first');
|
||||
setManagedRemoteValidationError(t('settings.openchamber.tunnel.toast.selectOrAddManagedRemoteFirst'));
|
||||
toast.error(t('settings.openchamber.tunnel.toast.selectOrAddManagedRemoteFirst'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -802,21 +820,21 @@ export const TunnelSettings: React.FC = () => {
|
||||
if (!res.ok || !data.ok) {
|
||||
if (tunnelMode === 'managed-remote' && typeof data.error === 'string' && data.error.includes('Managed remote tunnel token is required')) {
|
||||
setState('idle');
|
||||
setManagedRemoteValidationError('Managed remote tunnel token is required before starting');
|
||||
toast.error('Add a managed remote tunnel token before starting');
|
||||
setManagedRemoteValidationError(t('settings.openchamber.tunnel.toast.managedRemoteTokenRequiredBeforeStarting'));
|
||||
toast.error(t('settings.openchamber.tunnel.toast.addManagedRemoteTokenBeforeStarting'));
|
||||
return;
|
||||
}
|
||||
setState('error');
|
||||
setErrorMessage(data.error || 'Failed to start tunnel');
|
||||
toast.error(data.error || 'Failed to start tunnel');
|
||||
setErrorMessage(data.error || t('settings.openchamber.tunnel.toast.startFailed'));
|
||||
toast.error(data.error || t('settings.openchamber.tunnel.toast.startFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
const startedUrl = typeof data.url === 'string' ? data.url : '';
|
||||
if (!startedUrl) {
|
||||
setState('error');
|
||||
setErrorMessage('Tunnel started but no public URL was returned');
|
||||
toast.error('Tunnel started but no public URL was returned');
|
||||
setErrorMessage(t('settings.openchamber.tunnel.toast.startedButNoPublicUrl'));
|
||||
toast.error(t('settings.openchamber.tunnel.toast.startedButNoPublicUrl'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -844,20 +862,30 @@ export const TunnelSettings: React.FC = () => {
|
||||
if (data.replacedTunnel) {
|
||||
const revokedBootstrapCount = typeof data.revokedBootstrapCount === 'number' ? data.revokedBootstrapCount : 0;
|
||||
const invalidatedSessionCount = typeof data.invalidatedSessionCount === 'number' ? data.invalidatedSessionCount : 0;
|
||||
toast.warning(`Replaced previous tunnel: revoked ${revokedBootstrapCount} link${revokedBootstrapCount === 1 ? '' : 's'}, invalidated ${invalidatedSessionCount} session${invalidatedSessionCount === 1 ? '' : 's'}.`);
|
||||
if (revokedBootstrapCount === 1 && invalidatedSessionCount === 1) {
|
||||
toast.warning(t('settings.openchamber.tunnel.toast.replacedTunnelSingleSingle'));
|
||||
} else if (revokedBootstrapCount === 1) {
|
||||
toast.warning(t('settings.openchamber.tunnel.toast.replacedTunnelSingleManySessions', { invalidatedSessionCount }));
|
||||
} else if (invalidatedSessionCount === 1) {
|
||||
toast.warning(t('settings.openchamber.tunnel.toast.replacedTunnelManyLinksSingleSession', { revokedBootstrapCount }));
|
||||
} else {
|
||||
toast.warning(t('settings.openchamber.tunnel.toast.replacedTunnelManyMany', { revokedBootstrapCount, invalidatedSessionCount }));
|
||||
}
|
||||
} else {
|
||||
toast.success('Tunnel link ready');
|
||||
toast.success(t('settings.openchamber.tunnel.toast.linkReady'));
|
||||
}
|
||||
} catch {
|
||||
setState('error');
|
||||
setErrorMessage('Failed to start tunnel');
|
||||
toast.error('Failed to start tunnel');
|
||||
setErrorMessage(t('settings.openchamber.tunnel.toast.startFailed'));
|
||||
toast.error(t('settings.openchamber.tunnel.toast.startFailed'));
|
||||
}
|
||||
}, [
|
||||
managedLocalConfigExtensionError,
|
||||
managedRemoteTunnelPresets,
|
||||
saveTunnelSettings,
|
||||
selectedPreset,
|
||||
sessionTokensByPresetId,
|
||||
t,
|
||||
tunnelProvider,
|
||||
tunnelMode,
|
||||
managedLocalConfigPath,
|
||||
@@ -879,13 +907,13 @@ export const TunnelSettings: React.FC = () => {
|
||||
setActiveTunnelMode(null);
|
||||
setQrDataUrl(null);
|
||||
setState('idle');
|
||||
toast.success('Tunnel stopped');
|
||||
toast.success(t('settings.openchamber.tunnel.toast.stopped'));
|
||||
} catch {
|
||||
setState('error');
|
||||
setErrorMessage('Failed to stop tunnel');
|
||||
toast.error('Failed to stop tunnel');
|
||||
setErrorMessage(t('settings.openchamber.tunnel.toast.stopFailed'));
|
||||
toast.error(t('settings.openchamber.tunnel.toast.stopFailed'));
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleCopyUrl = React.useCallback(async () => {
|
||||
if (!tunnelInfo?.connectUrl) {
|
||||
@@ -895,12 +923,12 @@ export const TunnelSettings: React.FC = () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(tunnelInfo.connectUrl);
|
||||
setCopied(true);
|
||||
toast.success('Connect link copied');
|
||||
toast.success(t('settings.openchamber.tunnel.toast.connectLinkCopied'));
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error('Failed to copy URL');
|
||||
toast.error(t('settings.openchamber.tunnel.toast.copyUrlFailed'));
|
||||
}
|
||||
}, [tunnelInfo?.connectUrl]);
|
||||
}, [t, tunnelInfo?.connectUrl]);
|
||||
|
||||
const handleBootstrapTtlChange = React.useCallback(async (value: string) => {
|
||||
const option = BOOTSTRAP_TTL_OPTIONS.find((entry) => entry.value === value);
|
||||
@@ -939,9 +967,9 @@ export const TunnelSettings: React.FC = () => {
|
||||
managedRemoteTunnelPresets: presets,
|
||||
});
|
||||
} catch {
|
||||
toast.error('Failed to save selected managed remote tunnel');
|
||||
toast.error(t('settings.openchamber.tunnel.toast.saveSelectedManagedRemoteFailed'));
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleSelectPreset = React.useCallback((presetId: string) => {
|
||||
const preset = managedRemoteTunnelPresets.find((entry) => entry.id === presetId);
|
||||
@@ -960,20 +988,20 @@ export const TunnelSettings: React.FC = () => {
|
||||
const token = newPresetToken.trim();
|
||||
|
||||
if (!name) {
|
||||
toast.error('Tunnel name is required');
|
||||
toast.error(t('settings.openchamber.tunnel.toast.tunnelNameRequired'));
|
||||
return;
|
||||
}
|
||||
if (!hostname) {
|
||||
toast.error('Managed remote tunnel hostname is required');
|
||||
toast.error(t('settings.openchamber.tunnel.toast.managedRemoteHostnameRequired'));
|
||||
return;
|
||||
}
|
||||
if (!token) {
|
||||
toast.error('Managed remote tunnel token is required');
|
||||
toast.error(t('settings.openchamber.tunnel.toast.managedRemoteTokenRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (managedRemoteTunnelPresets.some((preset) => preset.hostname === hostname)) {
|
||||
toast.error('This hostname already exists');
|
||||
toast.error(t('settings.openchamber.tunnel.toast.hostnameAlreadyExists'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1008,8 +1036,8 @@ export const TunnelSettings: React.FC = () => {
|
||||
hostname: nextPreset.hostname,
|
||||
token,
|
||||
});
|
||||
toast.success('Managed remote tunnel saved');
|
||||
}, [managedRemoteTunnelPresets, newPresetHostname, newPresetName, newPresetToken, persistManagedRemoteTunnelToken, saveTunnelSettings, sessionTokensByPresetId]);
|
||||
toast.success(t('settings.openchamber.tunnel.toast.managedRemoteSaved'));
|
||||
}, [managedRemoteTunnelPresets, newPresetHostname, newPresetName, newPresetToken, persistManagedRemoteTunnelToken, saveTunnelSettings, sessionTokensByPresetId, t]);
|
||||
|
||||
const handleRemovePreset = React.useCallback(async (presetId: string) => {
|
||||
const preset = managedRemoteTunnelPresets.find((entry) => entry.id === presetId);
|
||||
@@ -1049,15 +1077,15 @@ export const TunnelSettings: React.FC = () => {
|
||||
managedRemoteTunnelPresetTokens: nextTokenMap,
|
||||
});
|
||||
|
||||
toast.success('Managed remote tunnel removed');
|
||||
}, [managedRemoteTunnelPresets, saveTunnelSettings, selectedPresetId, sessionTokensByPresetId]);
|
||||
toast.success(t('settings.openchamber.tunnel.toast.managedRemoteRemoved'));
|
||||
}, [managedRemoteTunnelPresets, saveTunnelSettings, selectedPresetId, sessionTokensByPresetId, t]);
|
||||
|
||||
const primaryCtaClass = 'gap-2 border-[var(--primary-base)] bg-[var(--primary-base)] text-[var(--primary-foreground)] hover:bg-[var(--primary-hover)] hover:text-[var(--primary-foreground)]';
|
||||
|
||||
if (state === 'checking') {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-current animate-busy-pulse" aria-label="Loading" />
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-current animate-busy-pulse" aria-label={t('settings.openchamber.tunnel.state.loading')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1065,15 +1093,15 @@ export const TunnelSettings: React.FC = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">Remote Tunnel</h3>
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">{t('settings.openchamber.tunnel.title')}</h3>
|
||||
<p className="typography-meta mt-0 text-muted-foreground/70">
|
||||
Configure secure remote access with quick links or your own managed remote Cloudflare tunnel.
|
||||
{t('settings.openchamber.tunnel.description')}
|
||||
</p>
|
||||
<p className="typography-meta mt-0 text-muted-foreground/60">
|
||||
Secure Tunnel access is enforced server-side.
|
||||
{t('settings.openchamber.tunnel.note.serverSideEnforced')}
|
||||
</p>
|
||||
<p className="typography-meta mt-0 text-muted-foreground/60">
|
||||
Connect links are one-time and are revoked when tunnel stops or Connect link TTL expired.
|
||||
{t('settings.openchamber.tunnel.note.connectLinksOneTime')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1082,7 +1110,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
<div className="rounded-lg border border-[var(--status-info-border)] bg-[var(--status-info-background)]/30 p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<RiInformationLine className="size-4 text-[var(--status-info)]" />
|
||||
<p className="typography-ui-label text-foreground">Redeemed access links</p>
|
||||
<p className="typography-ui-label text-foreground">{t('settings.openchamber.tunnel.section.redeemedAccessLinks')}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{renderedSessionRecords.map((record) => {
|
||||
@@ -1096,7 +1124,11 @@ export const TunnelSettings: React.FC = () => {
|
||||
const statusDotClass = record.isActive
|
||||
? (isQuick ? 'text-[var(--status-warning)]' : isManagedRemote ? 'text-[var(--status-info)]' : 'text-[var(--status-success)]')
|
||||
: 'text-muted-foreground/50';
|
||||
const modeLabel = isQuick ? 'QUICK' : isManagedRemote ? 'REMOTE' : 'LOCAL';
|
||||
const modeLabel = isQuick
|
||||
? t('settings.openchamber.tunnel.badge.quick')
|
||||
: isManagedRemote
|
||||
? t('settings.openchamber.tunnel.badge.remote')
|
||||
: t('settings.openchamber.tunnel.badge.local');
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1108,12 +1140,14 @@ export const TunnelSettings: React.FC = () => {
|
||||
{modeLabel}
|
||||
</span>
|
||||
<span className="typography-meta text-muted-foreground/80">
|
||||
Redeemed {formatAbsoluteTime(record.createdAt)}
|
||||
{t('settings.openchamber.tunnel.session.redeemedAt', { time: formatAbsoluteTime(record.createdAt) })}
|
||||
</span>
|
||||
<span className="typography-meta text-foreground">
|
||||
{record.isActive
|
||||
? `Expires in ${record.remainingTextForSession}`
|
||||
: (record.inactiveLabel === 'Inactive' ? 'Inactive' : `Inactive (${record.inactiveLabel})`)}
|
||||
? t('settings.openchamber.tunnel.session.expiresIn', { remaining: record.remainingTextForSession })
|
||||
: (record.inactiveLabel === t('settings.openchamber.tunnel.state.inactive')
|
||||
? t('settings.openchamber.tunnel.state.inactive')
|
||||
: t('settings.openchamber.tunnel.session.inactiveWithReason', { reason: record.inactiveLabel }))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -1128,8 +1162,8 @@ export const TunnelSettings: React.FC = () => {
|
||||
<div className="flex items-start gap-2 rounded-lg border border-[var(--status-warning)]/30 bg-[var(--status-warning)]/5 p-3">
|
||||
<RiErrorWarningLine className="mt-0.5 size-4 shrink-0 text-[var(--status-warning)]" />
|
||||
<div className="space-y-1">
|
||||
<p className="typography-meta font-medium text-foreground">cloudflared not found</p>
|
||||
<p className="typography-meta text-muted-foreground/70">Install it to enable remote tunnel access:</p>
|
||||
<p className="typography-meta font-medium text-foreground">{t('settings.openchamber.tunnel.notAvailable.cloudflaredNotFound')}</p>
|
||||
<p className="typography-meta text-muted-foreground/70">{t('settings.openchamber.tunnel.notAvailable.installHint')}</p>
|
||||
<code className="typography-code block rounded bg-muted/50 px-2 py-1 text-xs text-foreground">
|
||||
brew install cloudflared
|
||||
</code>
|
||||
@@ -1142,7 +1176,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
<section className="space-y-4 px-2 pb-2 pt-0">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<p className="typography-ui-label text-foreground">Provider</p>
|
||||
<p className="typography-ui-label text-foreground">{t('settings.openchamber.tunnel.field.provider')}</p>
|
||||
<Select
|
||||
value={tunnelProvider}
|
||||
onValueChange={(value) => {
|
||||
@@ -1151,7 +1185,9 @@ export const TunnelSettings: React.FC = () => {
|
||||
disabled={isSavingMode || state === 'starting' || state === 'stopping'}
|
||||
>
|
||||
<SelectTrigger className="max-w-[16rem]">
|
||||
<SelectValue placeholder="Select provider" />
|
||||
<SelectValue placeholder={t('settings.openchamber.tunnel.field.providerPlaceholder')}>
|
||||
{getProviderLabel(tunnelProvider)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerCapabilities.length > 0
|
||||
@@ -1165,13 +1201,13 @@ export const TunnelSettings: React.FC = () => {
|
||||
<ProviderOptionLabel provider="cloudflare" />
|
||||
</SelectItem>
|
||||
)}
|
||||
<SelectItem value="__more-soon" disabled>More providers coming soon</SelectItem>
|
||||
<SelectItem value="__more-soon" disabled>{t('settings.openchamber.tunnel.option.moreProvidersSoon')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="typography-ui-label text-foreground">Tunnel type</p>
|
||||
<p className="typography-ui-label text-foreground">{t('settings.openchamber.tunnel.field.tunnelType')}</p>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{TUNNEL_MODE_OPTIONS.map((option) => (
|
||||
<Tooltip key={option.value} delayDuration={700}>
|
||||
@@ -1186,11 +1222,11 @@ export const TunnelSettings: React.FC = () => {
|
||||
}}
|
||||
disabled={isSavingMode || state === 'starting' || state === 'stopping'}
|
||||
>
|
||||
{option.label}
|
||||
{tUnsafe(option.labelKey)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
{option.tooltip}
|
||||
{tUnsafe(option.tooltipKey)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
@@ -1200,7 +1236,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
|
||||
<div className="mt-2 grid grid-cols-1 gap-2 py-1.5 md:grid-cols-[14rem_auto] md:gap-x-8 md:gap-y-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="typography-ui-label shrink-0 text-foreground">Connect link TTL</span>
|
||||
<span className="typography-ui-label shrink-0 text-foreground">{t('settings.openchamber.tunnel.field.connectLinkTtl')}</span>
|
||||
<Select
|
||||
value={ttlOptionValue(BOOTSTRAP_TTL_OPTIONS, bootstrapTtlMs, '1800000')}
|
||||
onValueChange={(value) => {
|
||||
@@ -1220,7 +1256,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="typography-ui-label shrink-0 text-foreground">Tunnel session TTL</span>
|
||||
<span className="typography-ui-label shrink-0 text-foreground">{t('settings.openchamber.tunnel.field.tunnelSessionTtl')}</span>
|
||||
<Select
|
||||
value={ttlOptionValue(SESSION_TTL_OPTIONS, sessionTtlMs, '28800000')}
|
||||
onValueChange={(value) => {
|
||||
@@ -1246,10 +1282,10 @@ export const TunnelSettings: React.FC = () => {
|
||||
<RiErrorWarningLine className="mt-0.5 size-4 shrink-0 text-[var(--status-warning)]" />
|
||||
<div>
|
||||
<p className="typography-meta text-[var(--status-warning)]">
|
||||
Quick Tunnel is best effort and Cloudflare does not guarantee uptime.
|
||||
{t('settings.openchamber.tunnel.option.mode.quick.tooltip')}
|
||||
</p>
|
||||
<p className="typography-meta mt-1 text-[var(--status-warning)]">
|
||||
For more reliable long-lived access, switch to Managed Remote or Managed Local tunnel mode.
|
||||
{t('settings.openchamber.tunnel.warning.quickModeReliability')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1261,13 +1297,13 @@ export const TunnelSettings: React.FC = () => {
|
||||
{typeof suggestedConnectorPort === 'number' && (
|
||||
<div className="rounded-md border border-[var(--status-info-border)] bg-[var(--status-info-background)]/35 px-2 py-1.5">
|
||||
<p className="typography-meta text-[var(--status-info)]">
|
||||
Cloudflare connector target: <code>http://localhost:{suggestedConnectorPort}</code>
|
||||
{t('settings.openchamber.tunnel.note.cloudflareConnectorTarget')} <code>http://localhost:{suggestedConnectorPort}</code>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-1 flex items-center justify-between gap-3">
|
||||
<p className="typography-ui-label text-foreground">Saved managed remote tunnels</p>
|
||||
<p className="typography-ui-label text-foreground">{t('settings.openchamber.tunnel.section.savedManagedRemoteTunnels')}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
@@ -1276,7 +1312,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
disabled={state === 'starting' || state === 'stopping' || isSavingMode}
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
Add
|
||||
{t('settings.common.actions.create')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1318,7 +1354,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-[var(--status-error)]"
|
||||
aria-label={`Remove ${preset.name}`}
|
||||
aria-label={t('settings.openchamber.tunnel.actions.removePresetAria', { name: preset.name })}
|
||||
onClick={() => {
|
||||
void handleRemovePreset(preset.id);
|
||||
}}
|
||||
@@ -1330,7 +1366,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
|
||||
<CollapsibleContent className="pt-1.5">
|
||||
<div className="space-y-1 px-3 pb-2">
|
||||
<p className="typography-meta text-muted-foreground/70">Hostname: <code>{preset.hostname}</code></p>
|
||||
<p className="typography-meta text-muted-foreground/70">{t('settings.openchamber.tunnel.field.hostnameLabel')} <code>{preset.hostname}</code></p>
|
||||
<Input
|
||||
type="password"
|
||||
value={rowToken}
|
||||
@@ -1351,7 +1387,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
token: tokenToSave,
|
||||
});
|
||||
}}
|
||||
placeholder={hasSavedToken ? 'Saved token available (optional to replace)' : 'Paste token for this tunnel'}
|
||||
placeholder={hasSavedToken ? t('settings.openchamber.tunnel.field.savedTokenAvailablePlaceholder') : t('settings.openchamber.tunnel.field.pasteTokenPlaceholder')}
|
||||
className="h-7"
|
||||
disabled={state === 'starting' || state === 'stopping'}
|
||||
/>
|
||||
@@ -1370,7 +1406,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
Save token
|
||||
{t('settings.openchamber.tunnel.actions.saveToken')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1381,7 +1417,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="typography-meta text-muted-foreground/70">No managed remote tunnels saved yet.</p>
|
||||
<p className="typography-meta text-muted-foreground/70">{t('settings.openchamber.tunnel.empty.noManagedRemoteTunnels')}</p>
|
||||
)}
|
||||
|
||||
{isAddingPreset && (
|
||||
@@ -1389,14 +1425,14 @@ export const TunnelSettings: React.FC = () => {
|
||||
<Input
|
||||
value={newPresetName}
|
||||
onChange={(event) => setNewPresetName(event.target.value)}
|
||||
placeholder="Tunnel name (e.g. Production)"
|
||||
placeholder={t('settings.openchamber.tunnel.field.newPresetNamePlaceholder')}
|
||||
className="h-7"
|
||||
disabled={isSavingMode || state === 'starting' || state === 'stopping'}
|
||||
/>
|
||||
<Input
|
||||
value={newPresetHostname}
|
||||
onChange={(event) => setNewPresetHostname(event.target.value)}
|
||||
placeholder="Hostname (e.g. oc.example.com)"
|
||||
placeholder={t('settings.openchamber.tunnel.field.newPresetHostnamePlaceholder')}
|
||||
className="h-7"
|
||||
disabled={isSavingMode || state === 'starting' || state === 'stopping'}
|
||||
/>
|
||||
@@ -1404,13 +1440,13 @@ export const TunnelSettings: React.FC = () => {
|
||||
type="password"
|
||||
value={newPresetToken}
|
||||
onChange={(event) => setNewPresetToken(event.target.value)}
|
||||
placeholder="Token"
|
||||
placeholder={t('settings.openchamber.tunnel.field.newPresetTokenPlaceholder')}
|
||||
className="h-7"
|
||||
disabled={isSavingMode || state === 'starting' || state === 'stopping'}
|
||||
/>
|
||||
{typeof suggestedConnectorPort === 'number' && (
|
||||
<p className="typography-meta text-muted-foreground/70">
|
||||
For Cloudflare connector target, use <code>http://localhost:{suggestedConnectorPort}</code>.
|
||||
{t('settings.openchamber.tunnel.note.cloudflareConnectorTargetUse')} <code>http://localhost:{suggestedConnectorPort}</code>.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1423,7 +1459,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
}}
|
||||
disabled={isSavingMode || state === 'starting' || state === 'stopping'}
|
||||
>
|
||||
Save
|
||||
{t('settings.common.actions.saveChanges')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1437,26 +1473,26 @@ export const TunnelSettings: React.FC = () => {
|
||||
}}
|
||||
disabled={isSavingMode || state === 'starting' || state === 'stopping'}
|
||||
>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="typography-meta text-muted-foreground/80">Tokens are saved per tunnel and reused from disk</p>
|
||||
<p className="typography-meta text-muted-foreground/80">{t('settings.openchamber.tunnel.note.tokensSavedPerTunnel')}</p>
|
||||
<Tooltip delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-0.5 text-muted-foreground/70 hover:text-foreground"
|
||||
aria-label="Managed remote tunnel token info"
|
||||
aria-label={t('settings.openchamber.tunnel.field.managedRemoteTokenInfoAria')}
|
||||
>
|
||||
<RiInformationLine className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Tokens are saved in ~/.config/openchamber/cloudflare-managed-remote-tunnels.json.
|
||||
{t('settings.openchamber.tunnel.tooltip.tokensSavedPath')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -1470,7 +1506,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
{tunnelMode === 'managed-local' && (
|
||||
<div className="space-y-2 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-3">
|
||||
<div className="space-y-1.5">
|
||||
<p className="typography-ui-label text-foreground">Configuration file</p>
|
||||
<p className="typography-ui-label text-foreground">{t('settings.openchamber.tunnel.field.configurationFile')}</p>
|
||||
<input
|
||||
ref={managedLocalConfigFileInputRef}
|
||||
type="file"
|
||||
@@ -1489,7 +1525,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
onBlur={() => {
|
||||
void handleManagedLocalConfigInputBlur();
|
||||
}}
|
||||
placeholder="Using default cloudflared config"
|
||||
placeholder={t('settings.openchamber.tunnel.field.configurationFilePlaceholder')}
|
||||
className="h-7"
|
||||
disabled={state === 'starting' || state === 'stopping' || isSavingMode}
|
||||
/>
|
||||
@@ -1497,7 +1533,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="h-7 w-7 p-0"
|
||||
aria-label="Browse config file"
|
||||
aria-label={t('settings.openchamber.tunnel.actions.browseConfigFileAria')}
|
||||
onClick={() => {
|
||||
void handleBrowseManagedLocalConfig();
|
||||
}}
|
||||
@@ -1510,7 +1546,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-7 w-7 p-0"
|
||||
aria-label="Clear config file"
|
||||
aria-label={t('settings.openchamber.tunnel.actions.clearConfigFileAria')}
|
||||
onClick={() => {
|
||||
void handleManagedLocalConfigClear();
|
||||
}}
|
||||
@@ -1522,11 +1558,11 @@ export const TunnelSettings: React.FC = () => {
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground/70">
|
||||
{managedLocalConfigPath
|
||||
? 'Custom config file will be used when starting the tunnel.'
|
||||
: 'When empty, cloudflared uses its default config (~/.cloudflared/config.yml).'}
|
||||
? t('settings.openchamber.tunnel.note.customConfigUsed')
|
||||
: t('settings.openchamber.tunnel.note.defaultConfigUsed')}
|
||||
</p>
|
||||
{isManagedLocalConfigPathInvalid && (
|
||||
<p className="typography-meta text-[var(--status-error)]">{MANAGED_LOCAL_CONFIG_EXTENSION_ERROR}</p>
|
||||
<p className="typography-meta text-[var(--status-error)]">{managedLocalConfigExtensionError}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1541,7 +1577,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
{tunnelMode === 'managed-remote' && (
|
||||
<>
|
||||
<p className="typography-meta text-[var(--status-info)]">
|
||||
Managed remote tunnels require a bought domain in your Cloudflare account.
|
||||
{t('settings.openchamber.tunnel.note.managedRemoteRequiresDomain')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1550,7 +1586,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
void openExternal(MANAGED_REMOTE_TUNNEL_DOC_URL);
|
||||
}}
|
||||
>
|
||||
Check the documentation on how to configure a managed remote tunnel
|
||||
{t('settings.openchamber.tunnel.actions.openManagedRemoteDocs')}
|
||||
<RiExternalLinkLine className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
@@ -1558,7 +1594,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
{tunnelMode === 'managed-local' && (
|
||||
<>
|
||||
<p className="typography-meta text-[var(--status-info)]">
|
||||
Managed local tunnels use your local cloudflared configuration file.
|
||||
{t('settings.openchamber.tunnel.note.managedLocalUsesConfig')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1567,13 +1603,15 @@ export const TunnelSettings: React.FC = () => {
|
||||
void openExternal(MANAGED_LOCAL_TUNNEL_DOC_URL);
|
||||
}}
|
||||
>
|
||||
Check the documentation on managed local tunnel configuration
|
||||
{t('settings.openchamber.tunnel.actions.openManagedLocalDocs')}
|
||||
<RiExternalLinkLine className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<p className="typography-meta text-[var(--status-info)]">
|
||||
Start a {tunnelMode} tunnel and generate a one-time connect link. Do not close the app while this tunnel is in use.
|
||||
{t('settings.openchamber.tunnel.note.startModeAndGenerateLink', {
|
||||
mode: tUnsafe(TUNNEL_MODE_OPTIONS.find((option) => option.value === tunnelMode)?.labelKey ?? 'settings.openchamber.tunnel.option.mode.quick.label'),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1581,7 +1619,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
|
||||
{tunnelMode === 'managed-remote' && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="typography-ui-label text-foreground">Managed remote tunnel to connect</p>
|
||||
<p className="typography-ui-label text-foreground">{t('settings.openchamber.tunnel.field.managedRemoteTunnelToConnect')}</p>
|
||||
<Select
|
||||
value={selectedPresetId || (managedRemoteTunnelPresets[0]?.id ?? '')}
|
||||
onValueChange={(presetId) => {
|
||||
@@ -1595,7 +1633,9 @@ export const TunnelSettings: React.FC = () => {
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select saved tunnel" />
|
||||
<SelectValue placeholder={t('settings.openchamber.tunnel.field.selectSavedTunnelPlaceholder')}>
|
||||
{selectedPreset?.name}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent fitContent>
|
||||
{managedRemoteTunnelPresets.map((preset) => (
|
||||
@@ -1611,7 +1651,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
<div className="flex items-start gap-2">
|
||||
<RiErrorWarningLine className="mt-0.5 size-4 shrink-0 text-[var(--status-warning)]" />
|
||||
<p className="typography-meta text-[var(--status-warning)]">
|
||||
Starting this tunnel replaces the active tunnel and revokes existing connect links and remote sessions.
|
||||
{t('settings.openchamber.tunnel.warning.replacesActiveTunnel')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1629,8 +1669,8 @@ export const TunnelSettings: React.FC = () => {
|
||||
className={cn(primaryCtaClass, state === 'starting' && 'opacity-70')}
|
||||
>
|
||||
{state === 'starting'
|
||||
? <><RiLoader4Line className="size-3.5 animate-spin" /> Starting tunnel...</>
|
||||
: 'Start Tunnel'}
|
||||
? <><RiLoader4Line className="size-3.5 animate-spin" /> {t('settings.openchamber.tunnel.actions.startingTunnel')}</>
|
||||
: t('settings.openchamber.tunnel.actions.startTunnel')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1643,11 +1683,11 @@ export const TunnelSettings: React.FC = () => {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-2 shrink-0 rounded-full bg-[var(--status-success)]" />
|
||||
<p className="typography-meta font-medium text-foreground">Tunnel ready</p>
|
||||
<p className="typography-meta font-medium text-foreground">{t('settings.openchamber.tunnel.state.tunnelReady')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="typography-meta mb-1 text-muted-foreground/70">Public URL (Not accessible without a token)</p>
|
||||
<p className="typography-meta mb-1 text-muted-foreground/70">{t('settings.openchamber.tunnel.field.publicUrlHint')}</p>
|
||||
<code className="typography-code block truncate rounded bg-muted/50 px-2 py-1 text-xs text-foreground">
|
||||
{tunnelInfo.url}
|
||||
</code>
|
||||
@@ -1656,7 +1696,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
{isConnectLinkLive && tunnelInfo.connectUrl && (
|
||||
<>
|
||||
<div>
|
||||
<p className="typography-meta mb-1 text-muted-foreground/70">Connect link</p>
|
||||
<p className="typography-meta mb-1 text-muted-foreground/70">{t('settings.openchamber.tunnel.field.connectLink')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="typography-code flex-1 truncate rounded bg-muted/50 px-2 py-1 text-xs text-foreground">
|
||||
{tunnelInfo.connectUrl}
|
||||
@@ -1665,19 +1705,19 @@ export const TunnelSettings: React.FC = () => {
|
||||
{copied
|
||||
? <RiCheckLine className="size-3.5 text-[var(--status-success)]" />
|
||||
: <RiFileCopyLine className="size-3.5" />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
{copied ? t('settings.openchamber.tunnel.actions.copied') : t('settings.common.actions.copyAll')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="typography-meta mt-1 text-muted-foreground/70">
|
||||
Expires: {tunnelInfo.bootstrapExpiresAt ? remainingText : 'Never'}
|
||||
{t('settings.openchamber.tunnel.field.expires')}: {tunnelInfo.bootstrapExpiresAt ? remainingText : t('settings.openchamber.tunnel.state.never')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2 rounded-lg border border-border/50 bg-[var(--surface-elevated)] p-4">
|
||||
{qrDataUrl
|
||||
? <img src={qrDataUrl} alt="Tunnel connect QR code" className="size-48" />
|
||||
? <img src={qrDataUrl} alt={t('settings.openchamber.tunnel.field.connectQrAlt')} className="size-48" />
|
||||
: <div className="size-48 rounded bg-muted/30" />}
|
||||
<p className="typography-meta text-muted-foreground">Scan with your phone to connect</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.openchamber.tunnel.note.scanQrToConnect')}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -1692,7 +1732,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
className={primaryCtaClass}
|
||||
>
|
||||
<RiRestartLine className="size-3.5" />
|
||||
New connect link
|
||||
{t('settings.openchamber.tunnel.actions.newConnectLink')}
|
||||
</Button>
|
||||
|
||||
<Button size="sm"
|
||||
@@ -1702,8 +1742,8 @@ export const TunnelSettings: React.FC = () => {
|
||||
className="gap-2 text-[var(--status-error)]"
|
||||
>
|
||||
{state === 'stopping'
|
||||
? <><RiLoader4Line className="size-3.5 animate-spin" /> Stopping...</>
|
||||
: 'Stop Tunnel'}
|
||||
? <><RiLoader4Line className="size-3.5 animate-spin" /> {t('settings.openchamber.tunnel.actions.stopping')}</>
|
||||
: t('settings.openchamber.tunnel.actions.stopTunnel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1713,7 +1753,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
{state === 'error' && errorMessage && (
|
||||
<section className="space-y-3 px-2 pb-2 pt-0">
|
||||
<p className="typography-meta text-[var(--status-error)]">{errorMessage}</p>
|
||||
<Button size="sm" variant="ghost" onClick={handleStart}>Retry</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleStart}>{t('settings.openchamber.tunnel.actions.retry')}</Button>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { browserVoiceService } from '@/lib/voice/browserVoiceService';
|
||||
import { audioStreamService } from '@/lib/voice/audioStreamService';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
const LANGUAGE_OPTIONS = [
|
||||
{ value: 'en-US', label: 'English' },
|
||||
{ value: 'es-ES', label: 'Español' },
|
||||
@@ -48,6 +49,7 @@ const OPENAI_VOICE_OPTIONS = [
|
||||
];
|
||||
|
||||
export const VoiceSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const {
|
||||
isSupported,
|
||||
@@ -159,8 +161,8 @@ export const VoiceSettings: React.FC = () => {
|
||||
}
|
||||
|
||||
const selectedVoice = browserVoices.find(v => v.name === browserVoice);
|
||||
const voiceName = selectedVoice?.name ?? 'your browser voice';
|
||||
const previewText = `Hello! I'm ${voiceName}. This is how I sound.`;
|
||||
const voiceName = selectedVoice?.name ?? t('settings.voice.page.preview.browserVoiceFallback');
|
||||
const previewText = t('settings.voice.page.preview.voiceLine', { voiceName });
|
||||
|
||||
setIsBrowserPreviewPlaying(true);
|
||||
|
||||
@@ -250,7 +252,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: `Hello! I'm ${sayVoice}. This is how I sound.`,
|
||||
text: t('settings.voice.page.preview.voiceLine', { voiceName: sayVoice }),
|
||||
voice: sayVoice,
|
||||
rate: Math.round(100 + (speechRate - 0.5) * 200),
|
||||
}),
|
||||
@@ -279,7 +281,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
} catch {
|
||||
setIsPreviewPlaying(false);
|
||||
}
|
||||
}, [sayVoice, speechRate, previewAudio]);
|
||||
}, [sayVoice, speechRate, previewAudio, t]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -304,7 +306,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: `Hello! I'm ${openaiVoice}. This is how I sound.`,
|
||||
text: t('settings.voice.page.preview.voiceLine', { voiceName: openaiVoice }),
|
||||
voice: openaiVoice,
|
||||
speed: speechRate,
|
||||
apiKey: openaiApiKey || undefined,
|
||||
@@ -337,7 +339,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
} catch {
|
||||
setIsOpenAIPreviewPlaying(false);
|
||||
}
|
||||
}, [openaiVoice, speechRate, openaiPreviewAudio, openaiApiKey]);
|
||||
}, [openaiVoice, speechRate, openaiPreviewAudio, openaiApiKey, t]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -364,7 +366,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: `Hello! This is a preview of the custom TTS server.`,
|
||||
text: t('settings.voice.page.preview.customServerLine'),
|
||||
voice: openaiCompatibleVoice,
|
||||
model: openaiCompatibleTtsModel || undefined,
|
||||
speed: speechRate,
|
||||
@@ -398,7 +400,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
} catch {
|
||||
setIsCompatiblePreviewPlaying(false);
|
||||
}
|
||||
}, [openaiCompatibleUrl, openaiCompatibleVoice, openaiCompatibleTtsModel, speechRate, compatiblePreviewAudio]);
|
||||
}, [openaiCompatibleUrl, openaiCompatibleVoice, openaiCompatibleTtsModel, speechRate, compatiblePreviewAudio, t]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -417,7 +419,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Voice Setup
|
||||
{t('settings.voice.page.section.voiceSetup')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -431,8 +433,8 @@ export const VoiceSettings: React.FC = () => {
|
||||
onClick={() => setVoiceModeEnabled(!voiceModeEnabled)}
|
||||
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setVoiceModeEnabled(!voiceModeEnabled); } }}
|
||||
>
|
||||
<Checkbox checked={voiceModeEnabled} onChange={setVoiceModeEnabled} ariaLabel="Enable voice mode" />
|
||||
<span className="typography-ui-label text-foreground">Enable Voice Mode</span>
|
||||
<Checkbox checked={voiceModeEnabled} onChange={setVoiceModeEnabled} ariaLabel={t('settings.voice.page.field.enableVoiceModeAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.enableVoiceMode')}</span>
|
||||
</div>
|
||||
|
||||
{voiceModeEnabled && (
|
||||
@@ -440,17 +442,17 @@ export const VoiceSettings: React.FC = () => {
|
||||
<div className="pb-1.5 pt-0.5">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="typography-ui-label text-foreground">Provider</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.provider')}</span>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
<ul className="space-y-1">
|
||||
<li><strong>Browser:</strong> Free, offline, limited mobile support.</li>
|
||||
<li><strong>OpenAI:</strong> High quality, mobile ready, needs API key.</li>
|
||||
<li><strong>Custom:</strong> OpenAI-compatible server (e.g. Kokoro).</li>
|
||||
<li><strong>Say:</strong> macOS native. Fast, free, offline.</li>
|
||||
<li><strong>{t('settings.voice.page.provider.browser')}</strong> {t('settings.voice.page.tooltip.browser')}</li>
|
||||
<li><strong>OpenAI:</strong> {t('settings.voice.page.tooltip.openai')}</li>
|
||||
<li><strong>{t('settings.voice.page.provider.custom')}</strong> {t('settings.voice.page.tooltip.custom')}</li>
|
||||
<li><strong>{t('settings.voice.page.provider.say')}</strong> {t('settings.voice.page.tooltip.say')}</li>
|
||||
</ul>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -463,7 +465,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
onClick={() => setVoiceProvider('browser')}
|
||||
className="!font-normal"
|
||||
>
|
||||
Browser
|
||||
{t('settings.voice.page.provider.browser')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="chip"
|
||||
@@ -481,7 +483,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
onClick={() => setVoiceProvider('openai-compatible')}
|
||||
className="!font-normal"
|
||||
>
|
||||
Custom
|
||||
{t('settings.voice.page.provider.custom')}
|
||||
</Button>
|
||||
{isSayAvailable && (
|
||||
<Button
|
||||
@@ -492,7 +494,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
className="!font-normal"
|
||||
>
|
||||
<RiAppleLine className="w-3.5 h-3.5 mr-0.5" />
|
||||
Say
|
||||
{t('settings.voice.page.provider.say')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -503,10 +505,14 @@ export const VoiceSettings: React.FC = () => {
|
||||
{voiceProvider === 'openai' && (
|
||||
<div className="py-1.5">
|
||||
<span className={cn("typography-ui-label text-foreground", !isOpenAIAvailable && "text-[var(--status-error)]")}>
|
||||
API Key
|
||||
{t('settings.voice.page.field.apiKey')}
|
||||
</span>
|
||||
<span className={cn("typography-meta ml-2", !isOpenAIAvailable ? "text-[var(--status-error)]/80" : "text-muted-foreground")}>
|
||||
{isOpenAIAvailable && !openaiApiKey ? 'Using key from configuration' : !isOpenAIAvailable ? 'OpenAI TTS requires an API key' : 'Provide your OpenAI key'}
|
||||
{isOpenAIAvailable && !openaiApiKey
|
||||
? t('settings.voice.page.field.apiKeyHintUsingConfig')
|
||||
: !isOpenAIAvailable
|
||||
? t('settings.voice.page.field.apiKeyHintRequired')
|
||||
: t('settings.voice.page.field.apiKeyHintProvide')}
|
||||
</span>
|
||||
<div className="relative mt-1.5 max-w-xs">
|
||||
<input
|
||||
@@ -534,10 +540,10 @@ export const VoiceSettings: React.FC = () => {
|
||||
<div className="py-1.5 space-y-2">
|
||||
<div>
|
||||
<span className={cn("typography-ui-label text-foreground", !openaiCompatibleUrl.trim() && "text-[var(--status-error)]")}>
|
||||
Server URL
|
||||
{t('settings.voice.page.field.serverUrl')}
|
||||
</span>
|
||||
<span className="typography-meta ml-2 text-muted-foreground">
|
||||
Base URL of the OpenAI-compatible TTS server
|
||||
{t('settings.voice.page.field.serverUrlHint')}
|
||||
</span>
|
||||
<div className="relative mt-1.5 max-w-xs">
|
||||
<input
|
||||
@@ -559,7 +565,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="typography-ui-label text-foreground">Model</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.model')}</span>
|
||||
<div className="relative mt-1.5 max-w-xs">
|
||||
<input
|
||||
type="text"
|
||||
@@ -571,9 +577,9 @@ export const VoiceSettings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="typography-ui-label text-foreground">Voice</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.voice')}</span>
|
||||
<span className="typography-meta ml-2 text-muted-foreground">
|
||||
Voice identifier supported by the server
|
||||
{t('settings.voice.page.field.voiceIdentifierHint')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<div className="relative max-w-xs flex-1">
|
||||
@@ -585,7 +591,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
className="w-full h-7 rounded-lg border border-input bg-transparent px-2 typography-ui-label text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50 focus:border-primary/70"
|
||||
/>
|
||||
</div>
|
||||
<Button size="xs" variant="ghost" onClick={previewCompatibleVoice} title="Preview" disabled={!openaiCompatibleUrl.trim()}>
|
||||
<Button size="xs" variant="ghost" onClick={previewCompatibleVoice} title={t('settings.voice.page.actions.preview')} disabled={!openaiCompatibleUrl.trim()}>
|
||||
{isCompatiblePreviewPlaying ? <RiStopLine className="w-3.5 h-3.5" /> : <RiPlayLine className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -595,13 +601,13 @@ export const VoiceSettings: React.FC = () => {
|
||||
|
||||
{/* Voice Selection */}
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">Voice</span>
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.voice')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
{voiceProvider === 'openai' && isOpenAIAvailable && (
|
||||
<>
|
||||
<Select value={openaiVoice} onValueChange={setOpenaiVoice}>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectValue placeholder="Select voice" />
|
||||
<SelectValue placeholder={t('settings.voice.page.field.selectVoicePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPENAI_VOICE_OPTIONS.map((v) => (
|
||||
@@ -609,21 +615,21 @@ export const VoiceSettings: React.FC = () => {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="xs" variant="ghost" onClick={previewOpenAIVoice} title="Preview">
|
||||
<Button size="xs" variant="ghost" onClick={previewOpenAIVoice} title={t('settings.voice.page.actions.preview')}>
|
||||
{isOpenAIPreviewPlaying ? <RiStopLine className="w-3.5 h-3.5" /> : <RiPlayLine className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{voiceProvider === 'openai-compatible' && (
|
||||
<span className="typography-meta text-muted-foreground">Configured above</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.voice.page.field.configuredAbove')}</span>
|
||||
)}
|
||||
|
||||
{voiceProvider === 'say' && isSayAvailable && sayVoices.length > 0 && (
|
||||
<>
|
||||
<Select value={sayVoice} onValueChange={setSayVoice}>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectValue placeholder="Select voice" />
|
||||
<SelectValue placeholder={t('settings.voice.page.field.selectVoicePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sayVoices.map((v) => (
|
||||
@@ -631,7 +637,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="xs" variant="ghost" onClick={previewVoice} title="Preview">
|
||||
<Button size="xs" variant="ghost" onClick={previewVoice} title={t('settings.voice.page.actions.preview')}>
|
||||
{isPreviewPlaying ? <RiStopLine className="w-3.5 h-3.5" /> : <RiPlayLine className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</>
|
||||
@@ -641,16 +647,16 @@ export const VoiceSettings: React.FC = () => {
|
||||
<>
|
||||
<Select value={browserVoice || '__auto__'} onValueChange={(value) => setBrowserVoice(value === '__auto__' ? '' : value)}>
|
||||
<SelectTrigger className="w-fit max-w-[200px]">
|
||||
<SelectValue placeholder="Auto" />
|
||||
<SelectValue placeholder={t('settings.voice.page.field.auto')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__auto__">Auto</SelectItem>
|
||||
<SelectItem value="__auto__">{t('settings.voice.page.field.auto')}</SelectItem>
|
||||
{filteredBrowserVoices.map((v) => (
|
||||
<SelectItem key={v.name} value={v.name}>{v.name} ({v.lang})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="xs" variant="ghost" onClick={previewBrowserVoice} title="Preview">
|
||||
<Button size="xs" variant="ghost" onClick={previewBrowserVoice} title={t('settings.voice.page.actions.preview')}>
|
||||
{isBrowserPreviewPlaying ? <RiStopLine className="w-3.5 h-3.5" /> : <RiPlayLine className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</>
|
||||
@@ -660,7 +666,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
|
||||
{/* Speech Rate */}
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">Speech Rate</span>
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.speechRate')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
{!isMobile && <input type="range" min={0.5} max={2} step={0.1} value={speechRate} onChange={(e) => setSpeechRate(Number(e.target.value))} disabled={!isSupported} className={sliderClass} />}
|
||||
<NumberInput value={speechRate} onValueChange={setSpeechRate} min={0.5} max={2} step={0.1} className="w-16 tabular-nums" />
|
||||
@@ -669,7 +675,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
|
||||
{/* Speech Pitch */}
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">Speech Pitch</span>
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.speechPitch')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
{!isMobile && <input type="range" min={0.5} max={2} step={0.1} value={speechPitch} onChange={(e) => setSpeechPitch(Number(e.target.value))} disabled={!isSupported} className={sliderClass} />}
|
||||
<NumberInput value={speechPitch} onValueChange={setSpeechPitch} min={0.5} max={2} step={0.1} className="w-16 tabular-nums" />
|
||||
@@ -678,7 +684,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
|
||||
{/* Speech Volume */}
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">Speech Volume</span>
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.speechVolume')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
{!isMobile && <input type="range" min={0} max={1} step={0.1} value={speechVolume} onChange={(e) => setSpeechVolume(Number(e.target.value))} disabled={!isSupported} className={sliderClass} />}
|
||||
{isMobile ? (
|
||||
@@ -693,11 +699,11 @@ export const VoiceSettings: React.FC = () => {
|
||||
|
||||
{/* Language */}
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">Language</span>
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.language')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
<Select value={language} onValueChange={setLanguage} disabled={!isSupported}>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectValue placeholder="Select language" />
|
||||
<SelectValue placeholder={t('settings.voice.page.field.selectLanguagePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANGUAGE_OPTIONS.map((lang) => (
|
||||
@@ -717,7 +723,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Speech Recognition
|
||||
{t('settings.voice.page.section.speechRecognition')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -725,15 +731,15 @@ export const VoiceSettings: React.FC = () => {
|
||||
<div className="pb-1.5 pt-0.5">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="typography-ui-label text-foreground">Provider</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.provider')}</span>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
<ul className="space-y-1">
|
||||
<li><strong>Browser:</strong> Web Speech API (Chrome/Edge). Free, no setup.</li>
|
||||
<li><strong>Server:</strong> OpenAI-compatible Whisper server. Better accuracy, any language.</li>
|
||||
<li><strong>{t('settings.voice.page.provider.browser')}</strong> {t('settings.voice.page.tooltip.sttBrowser')}</li>
|
||||
<li><strong>{t('settings.voice.page.provider.server')}</strong> {t('settings.voice.page.tooltip.sttServer')}</li>
|
||||
</ul>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -746,7 +752,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
onClick={() => setSttProvider('browser')}
|
||||
className="!font-normal"
|
||||
>
|
||||
Browser
|
||||
{t('settings.voice.page.provider.browser')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="chip"
|
||||
@@ -755,7 +761,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
onClick={() => setSttProvider('server')}
|
||||
className="!font-normal"
|
||||
>
|
||||
Server
|
||||
{t('settings.voice.page.provider.server')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -765,15 +771,15 @@ export const VoiceSettings: React.FC = () => {
|
||||
<div className="py-1.5 space-y-2">
|
||||
{!audioStreamService.isSupported() && (
|
||||
<p className="typography-meta text-[var(--status-error)]">
|
||||
MediaRecorder or AudioContext is not available in this browser. Server STT may not work.
|
||||
{t('settings.voice.page.field.sttBrowserSupportError')}
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<span className={cn("typography-ui-label text-foreground", !sttServerUrl.trim() && "text-[var(--status-error)]")}>
|
||||
Server URL
|
||||
{t('settings.voice.page.field.serverUrl')}
|
||||
</span>
|
||||
<span className="typography-meta ml-2 text-muted-foreground">
|
||||
Base URL of the Whisper-compatible server
|
||||
{t('settings.voice.page.field.sttServerUrlHint')}
|
||||
</span>
|
||||
<div className="relative mt-1.5 max-w-xs">
|
||||
<input
|
||||
@@ -795,7 +801,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="typography-ui-label text-foreground">Model</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.model')}</span>
|
||||
<div className="relative mt-1.5 max-w-xs">
|
||||
<input
|
||||
type="text"
|
||||
@@ -807,9 +813,9 @@ export const VoiceSettings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="typography-ui-label text-foreground">Language</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.language')}</span>
|
||||
<span className="typography-meta ml-2 text-muted-foreground">
|
||||
BCP-47 code (e.g. en, fr). Leave blank for auto-detect.
|
||||
{t('settings.voice.page.field.sttLanguageHint')}
|
||||
</span>
|
||||
<div className="relative mt-1.5 max-w-[8rem]">
|
||||
<input
|
||||
@@ -822,7 +828,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-8 py-0.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">Silence Threshold</span>
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.silenceThreshold')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
{!isMobile && <input type="range" min={-60} max={-20} step={1} value={sttSilenceThresholdDb} onChange={(e) => setSttSilenceThresholdDb(Number(e.target.value))} className={sliderClass} />}
|
||||
<span className="typography-ui-label text-foreground tabular-nums min-w-[3.5rem] text-right">
|
||||
@@ -831,11 +837,11 @@ export const VoiceSettings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-8 py-0.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">Silence Hold</span>
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.silenceHold')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
{!isMobile && <input type="range" min={500} max={3000} step={100} value={sttSilenceHoldMs} onChange={(e) => setSttSilenceHoldMs(Number(e.target.value))} className={sliderClass} />}
|
||||
<NumberInput value={sttSilenceHoldMs} onValueChange={setSttSilenceHoldMs} min={500} max={3000} step={100} className="w-20 tabular-nums" />
|
||||
<span className="typography-meta text-muted-foreground">ms</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.voice.page.field.millisecondsUnit')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -848,7 +854,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Playback & Summarization
|
||||
{t('settings.voice.page.section.playbackAndSummary')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -861,8 +867,8 @@ export const VoiceSettings: React.FC = () => {
|
||||
onClick={() => setShowMessageTTSButtons(!showMessageTTSButtons)}
|
||||
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setShowMessageTTSButtons(!showMessageTTSButtons); } }}
|
||||
>
|
||||
<Checkbox checked={showMessageTTSButtons} onChange={setShowMessageTTSButtons} ariaLabel="Message read aloud button" />
|
||||
<span className="typography-ui-label text-foreground">Message Read Aloud Button</span>
|
||||
<Checkbox checked={showMessageTTSButtons} onChange={setShowMessageTTSButtons} ariaLabel={t('settings.voice.page.field.messageReadAloudButtonAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.messageReadAloudButton')}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -873,8 +879,8 @@ export const VoiceSettings: React.FC = () => {
|
||||
onClick={() => setSummarizeMessageTTS(!summarizeMessageTTS)}
|
||||
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setSummarizeMessageTTS(!summarizeMessageTTS); } }}
|
||||
>
|
||||
<Checkbox checked={summarizeMessageTTS} onChange={setSummarizeMessageTTS} ariaLabel="Summarize before playback" />
|
||||
<span className="typography-ui-label text-foreground">Summarize Before Playback</span>
|
||||
<Checkbox checked={summarizeMessageTTS} onChange={setSummarizeMessageTTS} ariaLabel={t('settings.voice.page.field.summarizeBeforePlaybackAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.summarizeBeforePlayback')}</span>
|
||||
</div>
|
||||
|
||||
{voiceModeEnabled && (
|
||||
@@ -886,15 +892,15 @@ export const VoiceSettings: React.FC = () => {
|
||||
onClick={() => setSummarizeVoiceConversation(!summarizeVoiceConversation)}
|
||||
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setSummarizeVoiceConversation(!summarizeVoiceConversation); } }}
|
||||
>
|
||||
<Checkbox checked={summarizeVoiceConversation} onChange={setSummarizeVoiceConversation} ariaLabel="Summarize voice mode responses" />
|
||||
<span className="typography-ui-label text-foreground">Summarize Voice Mode Responses</span>
|
||||
<Checkbox checked={summarizeVoiceConversation} onChange={setSummarizeVoiceConversation} ariaLabel={t('settings.voice.page.field.summarizeVoiceModeResponsesAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.summarizeVoiceModeResponses')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(summarizeMessageTTS || summarizeVoiceConversation) && (
|
||||
<>
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">Summarization Threshold</span>
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.summarizationThreshold')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
{!isMobile && <input type="range" min={50} max={2000} step={50} value={summarizeCharacterThreshold} onChange={(e) => setSummarizeCharacterThreshold(Number(e.target.value))} className={sliderClass} />}
|
||||
<NumberInput value={summarizeCharacterThreshold} onValueChange={setSummarizeCharacterThreshold} min={50} max={2000} step={50} className="w-16 tabular-nums" />
|
||||
@@ -902,7 +908,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">Summary Max Length</span>
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.summaryMaxLength')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
{!isMobile && <input type="range" min={50} max={2000} step={50} value={summarizeMaxLength} onChange={(e) => setSummarizeMaxLength(Number(e.target.value))} className={sliderClass} />}
|
||||
<NumberInput value={summarizeMaxLength} onValueChange={setSummarizeMaxLength} min={50} max={2000} step={50} className="w-16 tabular-nums" />
|
||||
@@ -915,7 +921,13 @@ export const VoiceSettings: React.FC = () => {
|
||||
{voiceModeEnabled && isSupported && (
|
||||
<div className="mt-2 px-2">
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Press <kbd className="px-1 py-0.5 mx-0.5 rounded border border-[var(--interactive-border)] bg-background typography-mono text-[10px]">Shift</kbd> + <kbd className="px-1 py-0.5 mx-0.5 rounded border border-[var(--interactive-border)] bg-background typography-mono text-[10px]">Click</kbd> on the mic button to toggle continuous mode
|
||||
{t('settings.voice.page.hint.shiftClickPrefix')}
|
||||
{' '}
|
||||
<kbd className="px-1 py-0.5 mx-0.5 rounded border border-[var(--interactive-border)] bg-background typography-mono text-[10px]">Shift</kbd>
|
||||
{' + '}
|
||||
<kbd className="px-1 py-0.5 mx-0.5 rounded border border-[var(--interactive-border)] bg-background typography-mono text-[10px]">Click</kbd>
|
||||
{' '}
|
||||
{t('settings.voice.page.hint.shiftClickSuffix')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -14,12 +14,14 @@ import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { formatPathForDisplay, cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export interface WorktreeSectionContentProps {
|
||||
projectRef?: { id: string; path: string } | null;
|
||||
}
|
||||
|
||||
export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({ projectRef: projectRefProp = null }) => {
|
||||
const { t } = useI18n();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const activeProject = useProjectsStore((state) => state.getActiveProject());
|
||||
|
||||
@@ -251,7 +253,7 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
||||
if (!projectPath) {
|
||||
return (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Select a project to manage worktrees.
|
||||
{t('settings.openchamber.worktrees.state.selectProject')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -259,7 +261,7 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
||||
if (isGitRepoLocal === false) {
|
||||
return (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Worktree settings are only available for Git repositories.
|
||||
{t('settings.openchamber.worktrees.state.gitOnly')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -270,21 +272,24 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
||||
<div className="space-y-2">
|
||||
<div className="mb-1 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-normal text-foreground">Setup commands</h3>
|
||||
<h3 className="typography-ui-header font-normal text-foreground">{t('settings.openchamber.worktrees.setup.title')}</h3>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Run automatically inside the new worktree directory when a worktree is created.
|
||||
Use <code className="font-mono text-xs bg-sidebar-accent/50 px-1 rounded">$ROOT_PROJECT_PATH</code> for the project root.
|
||||
{t('settings.openchamber.worktrees.setup.tooltipPrefix')}
|
||||
{' '}
|
||||
<code className="font-mono text-xs bg-sidebar-accent/50 px-1 rounded">$ROOT_PROJECT_PATH</code>
|
||||
{' '}
|
||||
{t('settings.openchamber.worktrees.setup.tooltipSuffix')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoadingCommands ? (
|
||||
<p className="typography-meta text-muted-foreground px-1">Loading...</p>
|
||||
<p className="typography-meta text-muted-foreground px-1">{t('settings.openchamber.worktrees.setup.loading')}</p>
|
||||
) : (
|
||||
<div className="space-y-2 px-1">
|
||||
{setupCommands.map((command, index) => (
|
||||
@@ -293,7 +298,7 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
||||
value={command}
|
||||
onChange={(e) => handleSetupCommandChange(index, e.target.value)}
|
||||
onBlur={handleCommandBlur}
|
||||
placeholder="e.g., bun install"
|
||||
placeholder={t('settings.openchamber.worktrees.setup.commandPlaceholder')}
|
||||
className="h-7 w-[30rem] max-w-full font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
@@ -302,7 +307,7 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
||||
handleRemoveCommand(index);
|
||||
}}
|
||||
className="flex-shrink-0 flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label="Remove command"
|
||||
aria-label={t('settings.openchamber.worktrees.setup.removeCommandAria')}
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -316,7 +321,7 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
||||
onClick={handleAddCommand}
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
Add command
|
||||
{t('settings.openchamber.worktrees.setup.addCommand')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -326,23 +331,23 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
||||
<div className="space-y-2 border-t border-border/40 pt-4">
|
||||
<div className="mb-1 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-normal text-foreground">Existing worktrees</h3>
|
||||
<h3 className="typography-ui-header font-normal text-foreground">{t('settings.openchamber.worktrees.list.title')}</h3>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Worktrees live outside the repo (OpenCode-managed). Deleting a worktree also removes linked sessions.
|
||||
{t('settings.openchamber.worktrees.list.tooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoadingWorktrees ? (
|
||||
<p className="typography-meta text-muted-foreground px-1">Loading worktrees...</p>
|
||||
<p className="typography-meta text-muted-foreground px-1">{t('settings.openchamber.worktrees.list.loading')}</p>
|
||||
) : availableWorktrees.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground/70 px-1">
|
||||
No worktrees found for this project
|
||||
{t('settings.openchamber.worktrees.list.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1 px-1 max-w-[32.5rem]">
|
||||
@@ -354,7 +359,7 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<p className="typography-meta text-foreground truncate min-w-0">
|
||||
{worktree.label || worktree.branch || 'Detached HEAD'}
|
||||
{worktree.label || worktree.branch || t('settings.openchamber.worktrees.list.detachedHead')}
|
||||
</p>
|
||||
<span className="typography-micro text-muted-foreground/60 px-1.5 py-[1px] rounded bg-sidebar-accent/40 flex-shrink-0 self-center leading-none">
|
||||
OpenCode
|
||||
@@ -371,7 +376,7 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
||||
"flex-shrink-0 flex h-7 w-7 items-center justify-center rounded text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50",
|
||||
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
||||
)}
|
||||
aria-label={`Delete worktree ${worktree.branch || worktree.label}`}
|
||||
aria-label={t('settings.openchamber.worktrees.list.deleteWorktreeAria', { name: worktree.branch || worktree.label || worktree.path })}
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
PROJECT_ACTION_ICONS,
|
||||
PROJECT_ACTIONS_UPDATED_EVENT,
|
||||
} from '@/lib/projectActions';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type EditableProjectAction = OpenChamberProjectAction;
|
||||
@@ -67,6 +68,7 @@ interface ProjectActionsSectionProps {
|
||||
}
|
||||
|
||||
export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ projectRef }) => {
|
||||
const { t } = useI18n();
|
||||
const isDesktopShellApp = React.useMemo(() => isDesktopShell(), []);
|
||||
const desktopSshInstances = useDesktopSshStore((state) => state.instances);
|
||||
const loadDesktopSsh = useDesktopSshStore((state) => state.load);
|
||||
@@ -126,10 +128,10 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
return entry.name.trim().length === 0 || entry.command.trim().length === 0;
|
||||
});
|
||||
if (hasIncomplete) {
|
||||
return 'Fill action name and command before saving.';
|
||||
return t('settings.projects.actions.validation.fillNameAndCommand');
|
||||
}
|
||||
return null;
|
||||
}, [actions]);
|
||||
}, [actions, t]);
|
||||
|
||||
const hasChanges = React.useMemo(() => {
|
||||
if (initialSnapshot === null) {
|
||||
@@ -172,7 +174,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
primaryActionId: null,
|
||||
});
|
||||
if (!ok) {
|
||||
toast.error('Failed to save actions');
|
||||
toast.error(t('settings.projects.actions.toast.saveFailed'));
|
||||
return;
|
||||
}
|
||||
setInitialSnapshot(JSON.stringify({ actions }));
|
||||
@@ -181,13 +183,13 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
detail: { projectId: projectRef.id },
|
||||
}));
|
||||
}
|
||||
toast.success('Project actions saved');
|
||||
toast.success(t('settings.projects.actions.toast.saved'));
|
||||
} catch {
|
||||
toast.error('Failed to save actions');
|
||||
toast.error(t('settings.projects.actions.toast.saveFailed'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [actions, projectRef, validationError]);
|
||||
}, [actions, projectRef, t, validationError]);
|
||||
|
||||
const canSave = !isSaving && !isLoading && hasChanges && !validationError;
|
||||
|
||||
@@ -195,21 +197,21 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Actions</h3>
|
||||
<p className="typography-meta text-muted-foreground">Per-project commands shown in header next to project name.</p>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.projects.actions.title')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.projects.actions.description')}</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={handleAddAction}>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
Add action
|
||||
{t('settings.projects.actions.actions.add')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<section className="pb-2 pt-0 space-y-2">
|
||||
{isLoading ? (
|
||||
<p className="typography-meta text-muted-foreground">Loading...</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.projects.actions.state.loading')}</p>
|
||||
) : actions.length === 0 ? (
|
||||
<div className="py-2">
|
||||
<p className="typography-meta text-muted-foreground">No actions configured yet.</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.projects.actions.state.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0 max-w-[30rem]">
|
||||
@@ -217,7 +219,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
const selectedIconKey = (action.icon as keyof typeof PROJECT_ACTION_ICON_MAP) || 'play';
|
||||
const SelectedIcon = PROJECT_ACTION_ICON_MAP[selectedIconKey] || RiPlayLine;
|
||||
const isOpen = expandedActions[action.id] ?? false;
|
||||
const title = action.name.trim() || 'Untitled action';
|
||||
const title = action.name.trim() || t('settings.projects.actions.state.untitled');
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
@@ -267,7 +269,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-[var(--interactive-border)] text-foreground hover:bg-[var(--interactive-hover)]"
|
||||
aria-label="Select icon"
|
||||
aria-label={t('settings.projects.actions.field.selectIconAria')}
|
||||
>
|
||||
<SelectedIcon className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -286,7 +288,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
'inline-flex h-8 w-8 items-center justify-center rounded-md border border-transparent text-foreground hover:bg-[var(--interactive-hover)]',
|
||||
selected && 'border-[var(--primary-base)] bg-[var(--primary-base)]/10 text-[var(--primary-base)]'
|
||||
)}
|
||||
aria-label={`Icon ${entry.label}`}
|
||||
aria-label={t('settings.projects.actions.field.iconAria', { icon: entry.label })}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -299,24 +301,24 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
<Input
|
||||
value={action.name}
|
||||
onChange={(event) => updateAction(action.id, (current) => ({ ...current, name: event.target.value }))}
|
||||
placeholder="Action name"
|
||||
placeholder={t('settings.projects.actions.field.actionNamePlaceholder')}
|
||||
className="h-7 max-w-[14rem]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="py-1">
|
||||
<p className="typography-meta mb-0.5 text-muted-foreground">Command</p>
|
||||
<p className="typography-meta mb-0.5 text-muted-foreground">{t('settings.projects.actions.field.command')}</p>
|
||||
<Textarea
|
||||
value={action.command}
|
||||
onChange={(event) => updateAction(action.id, (current) => ({ ...current, command: event.target.value }))}
|
||||
placeholder="e.g. bun run lint"
|
||||
placeholder={t('settings.projects.actions.field.commandPlaceholder')}
|
||||
className="min-h-[88px] max-w-[30rem] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="py-1">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="typography-ui-label text-foreground">Auto-open URL</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.projects.actions.field.autoOpenUrl')}</span>
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2"
|
||||
role="button"
|
||||
@@ -342,9 +344,9 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
...current,
|
||||
...(checked ? { autoOpenUrl: true } : { autoOpenUrl: undefined }),
|
||||
}))}
|
||||
ariaLabel={`Auto-open URL for ${title}`}
|
||||
ariaLabel={t('settings.projects.actions.field.autoOpenUrlForAria', { title })}
|
||||
/>
|
||||
<span className="typography-ui-label font-normal text-foreground/80">Open URL from output or custom URL below</span>
|
||||
<span className="typography-ui-label font-normal text-foreground/80">{t('settings.projects.actions.field.autoOpenUrlDescription')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -357,7 +359,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
...current,
|
||||
openUrl: event.target.value,
|
||||
}))}
|
||||
placeholder="Override URL (optional)"
|
||||
placeholder={t('settings.projects.actions.field.overrideUrlPlaceholder')}
|
||||
className="h-7 w-full max-w-[24rem]"
|
||||
/>
|
||||
<Tooltip delayDuration={1000}>
|
||||
@@ -365,14 +367,14 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
<RiInformationLine className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
If this field is filled, custom URL is used. If empty, app opens best URL from output.
|
||||
{t('settings.projects.actions.field.overrideUrlTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{isDesktopShellApp ? (
|
||||
<div className="mt-2">
|
||||
<p className="typography-meta mb-0.5 text-muted-foreground">Desktop SSH forward</p>
|
||||
<p className="typography-meta mb-0.5 text-muted-foreground">{t('settings.projects.actions.field.desktopSshForward')}</p>
|
||||
{desktopForwardOptions.length > 0 ? (
|
||||
<Select
|
||||
value={
|
||||
@@ -388,17 +390,17 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-full max-w-[30rem]">
|
||||
<SelectValue placeholder="Use output/manual URL" />
|
||||
<SelectValue placeholder={t('settings.projects.actions.field.useOutputManualUrl')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">Use output/manual URL</SelectItem>
|
||||
<SelectItem value="__none__">{t('settings.projects.actions.field.useOutputManualUrl')}</SelectItem>
|
||||
{desktopForwardOptions.map((entry) => (
|
||||
<SelectItem key={entry.id} value={entry.id}>{entry.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="typography-meta text-muted-foreground">No enabled local SSH forwards available.</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.projects.actions.state.noDesktopSshForwards')}</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -425,7 +427,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
onClick={handleSave}
|
||||
disabled={!canSave}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Actions'}
|
||||
{isSaving ? t('settings.common.actions.saving') : t('settings.projects.actions.actions.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -11,8 +11,10 @@ import { RiCloseLine } from '@remixicon/react';
|
||||
import { WorktreeSectionContent } from '@/components/sections/openchamber/WorktreeSectionContent';
|
||||
import { ProjectActionsSection } from '@/components/sections/projects/ProjectActionsSection';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export const ProjectsPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const updateProjectMeta = useProjectsStore((state) => state.updateProjectMeta);
|
||||
const uploadProjectIcon = useProjectsStore((state) => state.uploadProjectIcon);
|
||||
@@ -108,10 +110,10 @@ export const ProjectsPage: React.FC = () => {
|
||||
const uploadResult = await uploadProjectIcon(selectedProject.id, pendingUploadIconFile);
|
||||
setIsUploadingIcon(false);
|
||||
if (!uploadResult.ok) {
|
||||
toast.error(uploadResult.error || 'Failed to upload project icon');
|
||||
toast.error(uploadResult.error || t('settings.projects.page.toast.uploadIconFailed'));
|
||||
return;
|
||||
}
|
||||
toast.success('Project icon updated');
|
||||
toast.success(t('settings.projects.page.toast.iconUpdated'));
|
||||
clearPendingUploadIcon();
|
||||
setPendingRemoveImageIcon(false);
|
||||
}
|
||||
@@ -123,10 +125,10 @@ export const ProjectsPage: React.FC = () => {
|
||||
const removeResult = await removeProjectIcon(selectedProject.id);
|
||||
setIsRemovingCustomIcon(false);
|
||||
if (!removeResult.ok) {
|
||||
toast.error(removeResult.error || 'Failed to remove project icon');
|
||||
toast.error(removeResult.error || t('settings.projects.page.toast.removeIconFailed'));
|
||||
return;
|
||||
}
|
||||
toast.success('Project icon removed');
|
||||
toast.success(t('settings.projects.page.toast.iconRemoved'));
|
||||
setPendingRemoveImageIcon(false);
|
||||
setIconBackground(null);
|
||||
}
|
||||
@@ -220,25 +222,25 @@ export const ProjectsPage: React.FC = () => {
|
||||
void discoverProjectIcon(selectedProject.id)
|
||||
.then((result) => {
|
||||
if (!result.ok) {
|
||||
toast.error(result.error || 'Failed to discover project icon');
|
||||
toast.error(result.error || t('settings.projects.page.toast.discoverIconFailed'));
|
||||
return;
|
||||
}
|
||||
if (result.skipped) {
|
||||
toast.success('Custom icon already set for this project');
|
||||
toast.success(t('settings.projects.page.toast.customIconAlreadySet'));
|
||||
return;
|
||||
}
|
||||
toast.success('Project icon discovered');
|
||||
toast.success(t('settings.projects.page.toast.iconDiscovered'));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsDiscoveringIcon(false);
|
||||
});
|
||||
}, [clearPendingUploadIcon, discoverProjectIcon, isDiscoveringIcon, selectedProject]);
|
||||
}, [clearPendingUploadIcon, discoverProjectIcon, isDiscoveringIcon, selectedProject, t]);
|
||||
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="h-full" className="w-full">
|
||||
<div className="mx-auto w-full max-w-4xl p-3 sm:p-6 sm:pt-8">
|
||||
<p className="typography-meta text-muted-foreground">No projects available.</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.projects.page.empty.noProjects')}</p>
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
@@ -251,7 +253,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground truncate">
|
||||
{selectedProject.label ?? 'Project Settings'}
|
||||
{selectedProject.label ?? t('settings.projects.page.title.default')}
|
||||
</h2>
|
||||
<p className="typography-meta text-muted-foreground truncate" title={selectedProject.path}>
|
||||
{selectedProject.path}
|
||||
@@ -266,13 +268,13 @@ export const ProjectsPage: React.FC = () => {
|
||||
{/* Name */}
|
||||
<div className="py-1.5">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="typography-ui-label text-foreground">Project Name</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.projects.page.field.projectName')}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex min-w-0 items-center gap-2">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Project name"
|
||||
placeholder={t('settings.projects.page.field.projectNamePlaceholder')}
|
||||
className="h-7 min-w-0 w-full sm:max-w-[19rem]"
|
||||
/>
|
||||
</div>
|
||||
@@ -281,7 +283,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
{/* Color */}
|
||||
<div className="py-1.5">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="typography-ui-label text-foreground">Accent Color</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.projects.page.field.accentColor')}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
@@ -293,7 +295,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
? 'border-2 border-foreground bg-[var(--primary-base)]/10'
|
||||
: 'border-border/40 hover:border-border hover:bg-[var(--surface-muted)]'
|
||||
)}
|
||||
title="None"
|
||||
title={t('settings.projects.page.field.none')}
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
@@ -318,7 +320,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
{/* Icon */}
|
||||
<div className="py-1.5">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="typography-ui-label text-foreground">Project Icon</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.projects.page.field.projectIcon')}</span>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -341,7 +343,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
? 'border-2 border-foreground bg-[var(--primary-base)]/10'
|
||||
: 'border-border/40 hover:border-border hover:bg-[var(--surface-muted)]'
|
||||
)}
|
||||
title="None"
|
||||
title={t('settings.projects.page.field.none')}
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
@@ -367,7 +369,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
</div>
|
||||
{effectiveHasImageIcon && iconPreviewUrl && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="typography-meta text-muted-foreground">Preview</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.projects.page.field.preview')}</span>
|
||||
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-border/60 bg-[var(--surface-elevated)] p-1">
|
||||
<span
|
||||
className="inline-flex h-4 w-4 items-center justify-center overflow-hidden rounded-[2px]"
|
||||
@@ -391,7 +393,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
value={iconBackground ?? '#000000'}
|
||||
onChange={(event) => setIconBackground(event.target.value)}
|
||||
className="h-7 w-9 cursor-pointer rounded border border-border bg-transparent p-1"
|
||||
aria-label="Project icon background color"
|
||||
aria-label={t('settings.projects.page.field.projectIconBackgroundAria')}
|
||||
/>
|
||||
<Input
|
||||
value={iconBackground ?? ''}
|
||||
@@ -405,8 +407,8 @@ export const ProjectsPage: React.FC = () => {
|
||||
variant="outline"
|
||||
onClick={() => setIconBackground(null)}
|
||||
className="h-7 w-7 p-0"
|
||||
aria-label="Clear icon background"
|
||||
title="Clear background"
|
||||
aria-label={t('settings.projects.page.field.clearIconBackgroundAria')}
|
||||
title={t('settings.projects.page.field.clearBackground')}
|
||||
disabled={!iconBackground}
|
||||
>
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
@@ -422,7 +424,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploadingIcon}
|
||||
>
|
||||
{isUploadingIcon ? 'Uploading...' : 'Upload Icon'}
|
||||
{isUploadingIcon ? t('settings.projects.page.actions.uploading') : t('settings.projects.page.actions.uploadIcon')}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -431,7 +433,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
onClick={() => void handleDiscoverIcon()}
|
||||
disabled={isDiscoveringIcon}
|
||||
>
|
||||
{isDiscoveringIcon ? 'Discovering...' : 'Discover Favicon'}
|
||||
{isDiscoveringIcon ? t('settings.projects.page.actions.discovering') : t('settings.projects.page.actions.discoverFavicon')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -443,7 +445,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
onClick={() => void handleRemoveImageIcon()}
|
||||
disabled={isRemovingCustomIcon}
|
||||
>
|
||||
{isRemovingCustomIcon ? 'Removing...' : 'Remove Project Icon'}
|
||||
{isRemovingCustomIcon ? t('settings.projects.page.actions.removing') : t('settings.projects.page.actions.removeProjectIcon')}
|
||||
</Button>
|
||||
)}
|
||||
{pendingRemoveImageIcon && (
|
||||
@@ -454,7 +456,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
onClick={() => setPendingRemoveImageIcon(false)}
|
||||
disabled={isRemovingCustomIcon}
|
||||
>
|
||||
Undo Remove
|
||||
{t('settings.projects.page.actions.undoRemove')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -469,7 +471,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
>
|
||||
Save Changes
|
||||
{t('settings.common.actions.saveChanges')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -485,7 +487,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Worktree
|
||||
{t('settings.projects.page.section.worktree')}
|
||||
</h3>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
|
||||
@@ -11,8 +11,10 @@ import { isDesktopLocalOriginActive, isTauriShell, isVSCodeRuntime, requestDirec
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onItemSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const addProject = useProjectsStore((state) => state.addProject);
|
||||
const selectedId = useUIStore((state) => state.settingsProjectsSelectedId);
|
||||
@@ -34,23 +36,23 @@ export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onIte
|
||||
if (result.success && result.path) {
|
||||
const added = addProject(result.path, { id: result.projectId });
|
||||
if (!added) {
|
||||
toast.error('Failed to add project', {
|
||||
description: 'Please select a valid directory.',
|
||||
toast.error(t('sessions.sidebar.directory.errorAddProjectTitle'), {
|
||||
description: t('sessions.sidebar.directory.errorAddProjectDescription'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSelectedId(added.id);
|
||||
} else if (result.error && result.error !== 'Directory selection cancelled') {
|
||||
toast.error('Failed to select directory', {
|
||||
toast.error(t('sessions.sidebar.directory.errorSelectDirectoryTitle'), {
|
||||
description: result.error,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to select directory:', error);
|
||||
toast.error('Failed to select directory');
|
||||
toast.error(t('sessions.sidebar.directory.errorSelectDirectoryTitle'));
|
||||
});
|
||||
}, [addProject, setSelectedId, tauriIpcAvailable]);
|
||||
}, [addProject, setSelectedId, tauriIpcAvailable, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (projects.length === 0) {
|
||||
@@ -70,9 +72,9 @@ export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onIte
|
||||
variant="background"
|
||||
header={
|
||||
<div className={cn('border-b px-3', 'pt-4 pb-3')}>
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">Projects</h2>
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.page.projects.title')}</h2>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">Total {projects.length}</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.projects.sidebar.total', { count: projects.length })}</span>
|
||||
{!isVSCode && (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -80,7 +82,7 @@ export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onIte
|
||||
size="icon"
|
||||
className="h-7 w-7 -my-1 text-muted-foreground"
|
||||
onClick={handleAddProject}
|
||||
aria-label="Add project"
|
||||
aria-label={t('settings.projects.sidebar.actions.addProject')}
|
||||
>
|
||||
<RiAddLine className="size-4" />
|
||||
</Button>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { cn } from '@/lib/utils';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import type { ModelMetadata } from '@/types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
@@ -139,6 +140,7 @@ const parseProvidersPayload = (payload: unknown): ProviderOption[] => {
|
||||
};
|
||||
|
||||
export const ProvidersPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
const selectedProviderId = useConfigStore((state) => state.selectedProviderId);
|
||||
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
|
||||
@@ -192,7 +194,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
} catch (error) {
|
||||
if (!isMounted) return;
|
||||
console.error('Failed to load provider auth methods:', error);
|
||||
toast.error('Failed to load provider authentication methods');
|
||||
toast.error(t('settings.providers.page.toast.authMethodsLoadFailed'));
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setAuthLoading(false);
|
||||
@@ -229,7 +231,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
} catch (error) {
|
||||
if (!isMounted) return;
|
||||
console.error('Failed to load available providers:', error);
|
||||
setAvailableError('Unable to load provider list');
|
||||
setAvailableError(t('settings.providers.page.state.unableToLoadProviderList'));
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setAvailableLoading(false);
|
||||
@@ -296,7 +298,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error || 'Failed to load provider sources');
|
||||
throw new Error(payload?.error || t('settings.providers.page.toast.providerSourcesLoadFailed'));
|
||||
}
|
||||
|
||||
const sources = (payload?.sources ?? payload?.data?.sources) as ProviderSources | undefined;
|
||||
@@ -326,7 +328,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
const handleSaveApiKey = async (providerId: string) => {
|
||||
const apiKey = apiKeyInputs[providerId]?.trim() ?? '';
|
||||
if (!apiKey) {
|
||||
toast.error('API key is required');
|
||||
toast.error(t('settings.providers.page.toast.apiKeyRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -342,17 +344,17 @@ export const ProvidersPage: React.FC = () => {
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const message = payload?.error || 'Failed to save API key';
|
||||
const message = payload?.error || t('settings.providers.page.toast.apiKeySaveFailed');
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
toast.success('API key saved');
|
||||
toast.success(t('settings.providers.page.toast.apiKeySaved'));
|
||||
setApiKeyInputs((prev) => ({ ...prev, [providerId]: '' }));
|
||||
await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" });
|
||||
setSelectedProvider(providerId);
|
||||
} catch (error) {
|
||||
console.error('Failed to save API key:', error);
|
||||
toast.error('Failed to save API key');
|
||||
toast.error(t('settings.providers.page.toast.apiKeySaveFailed'));
|
||||
} finally {
|
||||
setAuthBusyKey(null);
|
||||
}
|
||||
@@ -371,7 +373,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const message = payload?.error || 'Failed to start OAuth flow';
|
||||
const message = payload?.error || t('settings.providers.page.toast.oauthStartFailed');
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
@@ -393,7 +395,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
undefined;
|
||||
|
||||
if (!urlCandidate && !instructions && !userCode) {
|
||||
throw new Error('No OAuth details returned');
|
||||
throw new Error(t('settings.providers.page.toast.oauthDetailsMissing'));
|
||||
}
|
||||
|
||||
const detailsKey = `${providerId}:${methodIndex}`;
|
||||
@@ -410,10 +412,10 @@ export const ProvidersPage: React.FC = () => {
|
||||
void openExternalUrl(urlCandidate);
|
||||
}
|
||||
setPendingOAuth({ providerId, methodIndex });
|
||||
toast.message('Complete the OAuth flow in your browser');
|
||||
toast.message(t('settings.providers.page.toast.completeOAuthInBrowser'));
|
||||
} catch (error) {
|
||||
console.error('Failed to start OAuth flow:', error);
|
||||
toast.error('Failed to start OAuth flow');
|
||||
toast.error(t('settings.providers.page.toast.oauthStartFailed'));
|
||||
} finally {
|
||||
setAuthBusyKey(null);
|
||||
}
|
||||
@@ -440,18 +442,18 @@ export const ProvidersPage: React.FC = () => {
|
||||
|
||||
const responsePayload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const message = responsePayload?.error || 'Failed to complete OAuth flow';
|
||||
const message = responsePayload?.error || t('settings.providers.page.toast.oauthCompleteFailed');
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
toast.success('OAuth connection completed');
|
||||
toast.success(t('settings.providers.page.toast.oauthCompleted'));
|
||||
setOauthCodes((prev) => ({ ...prev, [codeKey]: '' }));
|
||||
setPendingOAuth(null);
|
||||
await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" });
|
||||
setSelectedProvider(providerId);
|
||||
} catch (error) {
|
||||
console.error('Failed to complete OAuth flow:', error);
|
||||
toast.error('Failed to complete OAuth flow');
|
||||
toast.error(t('settings.providers.page.toast.oauthCompleteFailed'));
|
||||
} finally {
|
||||
setAuthBusyKey(null);
|
||||
}
|
||||
@@ -460,21 +462,21 @@ export const ProvidersPage: React.FC = () => {
|
||||
const handleCopyOAuthLink = async (url: string) => {
|
||||
const result = await copyTextToClipboard(url);
|
||||
if (result.ok) {
|
||||
toast.success('OAuth link copied');
|
||||
toast.success(t('settings.providers.page.toast.oauthLinkCopied'));
|
||||
return;
|
||||
}
|
||||
console.error('Failed to copy OAuth link:', result.error);
|
||||
toast.error('Failed to copy OAuth link');
|
||||
toast.error(t('settings.providers.page.toast.oauthLinkCopyFailed'));
|
||||
};
|
||||
|
||||
const handleCopyOAuthCode = async (code: string) => {
|
||||
const result = await copyTextToClipboard(code);
|
||||
if (result.ok) {
|
||||
toast.success('Device code copied');
|
||||
toast.success(t('settings.providers.page.toast.deviceCodeCopied'));
|
||||
return;
|
||||
}
|
||||
console.error('Failed to copy device code:', result.error);
|
||||
toast.error('Failed to copy device code');
|
||||
toast.error(t('settings.providers.page.toast.deviceCodeCopyFailed'));
|
||||
};
|
||||
|
||||
const handleDisconnectProvider = async (providerId: string) => {
|
||||
@@ -489,15 +491,15 @@ export const ProvidersPage: React.FC = () => {
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const message = payload?.error || 'Failed to disconnect provider';
|
||||
const message = payload?.error || t('settings.providers.page.toast.providerDisconnectFailed');
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
toast.success('Provider disconnected');
|
||||
toast.success(t('settings.providers.page.toast.providerDisconnected'));
|
||||
await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" });
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect provider:', error);
|
||||
toast.error('Failed to disconnect provider');
|
||||
toast.error(t('settings.providers.page.toast.providerDisconnectFailed'));
|
||||
} finally {
|
||||
setAuthBusyKey(null);
|
||||
}
|
||||
@@ -510,8 +512,8 @@ export const ProvidersPage: React.FC = () => {
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<RiStackLine className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
||||
<p className="typography-body">No providers detected</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Check your OpenCode configuration</p>
|
||||
<p className="typography-body">{t('settings.providers.page.empty.noProvidersDetected')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.providers.page.empty.checkOpenCodeConfiguration')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -522,23 +524,23 @@ export const ProvidersPage: React.FC = () => {
|
||||
<ScrollableOverlay outerClassName="h-full" className="w-full">
|
||||
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
|
||||
<div className="mb-4">
|
||||
<h1 className="typography-ui-header font-semibold text-foreground">Connect Provider</h1>
|
||||
<h1 className="typography-ui-header font-semibold text-foreground">{t('settings.providers.page.connect.title')}</h1>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h2 className="typography-ui-header font-medium text-foreground">Select Provider</h2>
|
||||
<h2 className="typography-ui-header font-medium text-foreground">{t('settings.providers.page.connect.selectProviderTitle')}</h2>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
<div className="flex flex-wrap items-center gap-2 py-1.5">
|
||||
<span className="typography-ui-label text-foreground">Provider</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.providers.page.connect.providerField')}</span>
|
||||
{availableLoading ? (
|
||||
<p className="typography-meta text-muted-foreground">Loading...</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.state.loading')}</p>
|
||||
) : availableError ? (
|
||||
<p className="typography-meta text-muted-foreground">{availableError}</p>
|
||||
) : unconnectedProviders.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">All providers connected.</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.connect.allProvidersConnected')}</p>
|
||||
) : (
|
||||
<DropdownMenu open={providerDropdownOpen} onOpenChange={(open) => {
|
||||
setProviderDropdownOpen(open);
|
||||
@@ -556,7 +558,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
<span className={cn("truncate typography-ui-label font-normal", candidateProviderId ? "text-foreground" : "text-muted-foreground")}>
|
||||
{candidateProviderId
|
||||
? (unconnectedProviders.find(p => p.id === candidateProviderId)?.name || candidateProviderId)
|
||||
: "Select provider"}
|
||||
: t('settings.providers.page.connect.selectProviderPlaceholder')}
|
||||
</span>
|
||||
</span>
|
||||
<RiArrowDownSLine className="h-4 w-4 flex-shrink-0 text-muted-foreground/50" />
|
||||
@@ -577,7 +579,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
value={providerSearchQuery}
|
||||
onChange={(e) => setProviderSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
placeholder="Search..."
|
||||
placeholder={t('settings.providers.page.connect.searchProvidersPlaceholder')}
|
||||
className="flex-1 bg-transparent typography-meta outline-none placeholder:text-muted-foreground"
|
||||
autoFocus
|
||||
/>
|
||||
@@ -589,7 +591,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
return (p.name || p.id).toLowerCase().includes(query) || p.id.toLowerCase().includes(query);
|
||||
});
|
||||
if (filtered.length === 0) {
|
||||
return <p className="py-4 text-center typography-meta text-muted-foreground">No providers found</p>;
|
||||
return <p className="py-4 text-center typography-meta text-muted-foreground">{t('settings.providers.page.connect.noProvidersFound')}</p>;
|
||||
}
|
||||
return filtered.map((provider) => (
|
||||
<DropdownMenuItem
|
||||
@@ -622,22 +624,22 @@ export const ProvidersPage: React.FC = () => {
|
||||
{candidateProviderId && (
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h2 className="typography-ui-header font-medium text-foreground">Authentication</h2>
|
||||
<h2 className="typography-ui-header font-medium text-foreground">{t('settings.providers.page.auth.title')}</h2>
|
||||
</div>
|
||||
|
||||
{authLoading ? (
|
||||
<p className="typography-meta text-muted-foreground px-2">Loading authentication methods...</p>
|
||||
<p className="typography-meta text-muted-foreground px-2">{t('settings.providers.page.auth.loadingMethods')}</p>
|
||||
) : (
|
||||
<section className="px-2 pb-2 pt-0 space-y-4">
|
||||
<div className="py-1.5">
|
||||
<label className="typography-ui-label text-foreground flex items-center gap-1.5">
|
||||
API Key
|
||||
{t('settings.providers.page.auth.apiKeyLabel')}
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Keys are sent directly to OpenCode and never stored by OpenChamber.
|
||||
{t('settings.providers.page.auth.apiKeyTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
@@ -651,7 +653,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
[candidateProviderId]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="sk-..."
|
||||
placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')}
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
@@ -660,7 +662,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
onClick={() => handleSaveApiKey(candidateProviderId)}
|
||||
disabled={authBusyKey === `api:${candidateProviderId}`}
|
||||
>
|
||||
{authBusyKey === `api:${candidateProviderId}` ? 'Saving...' : 'Save Key'}
|
||||
{authBusyKey === `api:${candidateProviderId}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.saveKey')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -678,7 +680,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
return (
|
||||
<div className="space-y-4 border-t border-[var(--surface-subtle)] pt-2">
|
||||
{candidateOAuthMethods.map((method, index) => {
|
||||
const methodLabel = method.label || method.name || `OAuth method ${index + 1}`;
|
||||
const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(index + 1) });
|
||||
const codeKey = `${candidateProviderId}:${index}`;
|
||||
const isPending =
|
||||
pendingOAuth?.providerId === candidateProviderId && pendingOAuth?.methodIndex === index;
|
||||
@@ -701,7 +703,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
onClick={() => handleOAuthStart(candidateProviderId, index)}
|
||||
disabled={authBusyKey === `oauth:${candidateProviderId}:${index}`}
|
||||
>
|
||||
Connect
|
||||
{t('settings.providers.page.actions.connect')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -714,7 +716,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
{oauthDetails[codeKey]?.userCode && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input value={oauthDetails[codeKey]?.userCode} readOnly className="font-mono text-center tracking-widest" />
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>Copy Code</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>{t('settings.providers.page.actions.copyCode')}</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -722,8 +724,8 @@ export const ProvidersPage: React.FC = () => {
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" />
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => openExternalUrl(oauthDetails[codeKey]?.url ?? '')}>Open</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>Copy</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => openExternalUrl(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.open')}</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.copy')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -738,7 +740,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
[codeKey]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Paste authorization code"
|
||||
placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
@@ -747,7 +749,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
onClick={() => handleOAuthComplete(candidateProviderId, index)}
|
||||
disabled={authBusyKey === `oauth-complete:${candidateProviderId}:${index}`}
|
||||
>
|
||||
{authBusyKey === `oauth-complete:${candidateProviderId}:${index}` ? 'Saving...' : 'Complete'}
|
||||
{authBusyKey === `oauth-complete:${candidateProviderId}:${index}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.complete')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -771,8 +773,8 @@ export const ProvidersPage: React.FC = () => {
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<RiStackLine className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
||||
<p className="typography-body">Select a provider from the sidebar</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Review details and configure auth</p>
|
||||
<p className="typography-body">{t('settings.providers.page.empty.selectProviderFromSidebar')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.providers.page.empty.reviewDetailsAndConfigureAuth')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -810,14 +812,14 @@ export const ProvidersPage: React.FC = () => {
|
||||
{/* Authentication */}
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1 flex items-center justify-between gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Authentication</h3>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.providers.page.auth.title')}</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => setShowAuthPanel((prev) => !prev)}
|
||||
>
|
||||
{showAuthPanel ? 'Hide' : 'Reconnect'}
|
||||
{showAuthPanel ? t('settings.providers.page.actions.hide') : t('settings.providers.page.actions.reconnect')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -825,22 +827,22 @@ export const ProvidersPage: React.FC = () => {
|
||||
{!showAuthPanel ? (
|
||||
<div className="flex items-center gap-1.5 py-1.5">
|
||||
<RiCheckLine className="w-4 h-4 text-[var(--status-success)] shrink-0" />
|
||||
<span className="typography-ui-label text-foreground">Connected</span>
|
||||
<span className="typography-meta text-muted-foreground ml-1">· Use Reconnect to update credentials</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.providers.page.auth.connected')}</span>
|
||||
<span className="typography-meta text-muted-foreground ml-1">{t('settings.providers.page.auth.useReconnectHint')}</span>
|
||||
</div>
|
||||
) : authLoading ? (
|
||||
<div className="py-1.5 typography-meta text-muted-foreground">Loading authentication methods...</div>
|
||||
<div className="py-1.5 typography-meta text-muted-foreground">{t('settings.providers.page.auth.loadingMethods')}</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="py-1.5">
|
||||
<label className="typography-ui-label text-foreground flex items-center gap-1.5">
|
||||
API Key
|
||||
{t('settings.providers.page.auth.apiKeyLabel')}
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Keys are sent directly to OpenCode and never stored by OpenChamber.
|
||||
{t('settings.providers.page.auth.apiKeyTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
@@ -854,7 +856,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
[selectedProvider.id]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="sk-..."
|
||||
placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')}
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
@@ -863,7 +865,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
onClick={() => handleSaveApiKey(selectedProvider.id)}
|
||||
disabled={authBusyKey === `api:${selectedProvider.id}`}
|
||||
>
|
||||
{authBusyKey === `api:${selectedProvider.id}` ? 'Saving...' : 'Save Key'}
|
||||
{authBusyKey === `api:${selectedProvider.id}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.saveKey')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -871,7 +873,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
{oauthAuthMethods.length > 0 && (
|
||||
<div className="space-y-4 border-t border-[var(--surface-subtle)] pt-2">
|
||||
{oauthAuthMethods.map((method, index) => {
|
||||
const methodLabel = method.label || method.name || `OAuth method ${index + 1}`;
|
||||
const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(index + 1) });
|
||||
const codeKey = `${selectedProvider.id}:${index}`;
|
||||
const isPending =
|
||||
pendingOAuth?.providerId === selectedProvider.id && pendingOAuth?.methodIndex === index;
|
||||
@@ -894,7 +896,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
onClick={() => handleOAuthStart(selectedProvider.id, index)}
|
||||
disabled={authBusyKey === `oauth:${selectedProvider.id}:${index}`}
|
||||
>
|
||||
Connect
|
||||
{t('settings.providers.page.actions.connect')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -907,7 +909,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
{oauthDetails[codeKey]?.userCode && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input value={oauthDetails[codeKey]?.userCode} readOnly className="font-mono text-center tracking-widest" />
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>Copy Code</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>{t('settings.providers.page.actions.copyCode')}</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -915,8 +917,8 @@ export const ProvidersPage: React.FC = () => {
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" />
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => openExternalUrl(oauthDetails[codeKey]?.url ?? '')}>Open</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>Copy</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => openExternalUrl(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.open')}</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.copy')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -931,7 +933,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
[codeKey]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Paste authorization code"
|
||||
placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
@@ -940,7 +942,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
onClick={() => handleOAuthComplete(selectedProvider.id, index)}
|
||||
disabled={authBusyKey === `oauth-complete:${selectedProvider.id}:${index}`}
|
||||
>
|
||||
{authBusyKey === `oauth-complete:${selectedProvider.id}:${index}` ? 'Saving...' : 'Complete'}
|
||||
{authBusyKey === `oauth-complete:${selectedProvider.id}:${index}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.complete')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -957,7 +959,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
{/* Connection Details */}
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Connection Details</h3>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.providers.page.connectionDetails.title')}</h3>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
@@ -965,15 +967,16 @@ export const ProvidersPage: React.FC = () => {
|
||||
<div className="flex min-w-0 flex-col">
|
||||
{selectedSources && (selectedSources.auth.exists || selectedSources.user.exists || selectedSources.project.exists || selectedSources.custom?.exists) ? (
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
Configured in: {[
|
||||
selectedSources.auth.exists ? 'auth credentials' : null,
|
||||
selectedSources.user.exists ? 'user config' : null,
|
||||
selectedSources.project.exists ? 'project config' : null,
|
||||
selectedSources.custom?.exists ? 'custom config' : null,
|
||||
{t('settings.providers.page.connectionDetails.configuredIn')}{' '}
|
||||
{[
|
||||
selectedSources.auth.exists ? t('settings.providers.page.connectionDetails.source.authCredentials') : null,
|
||||
selectedSources.user.exists ? t('settings.providers.page.connectionDetails.source.userConfig') : null,
|
||||
selectedSources.project.exists ? t('settings.providers.page.connectionDetails.source.projectConfig') : null,
|
||||
selectedSources.custom?.exists ? t('settings.providers.page.connectionDetails.source.customConfig') : null,
|
||||
].filter(Boolean).join(', ')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="typography-meta text-muted-foreground">No active configuration source</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.providers.page.connectionDetails.noActiveSource')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -984,7 +987,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
onClick={() => handleDisconnectProvider(selectedProvider.id)}
|
||||
disabled={authBusyKey === `disconnect:${selectedProvider.id}`}
|
||||
>
|
||||
{authBusyKey === `disconnect:${selectedProvider.id}` ? 'Disconnecting...' : 'Disconnect'}
|
||||
{authBusyKey === `disconnect:${selectedProvider.id}` ? t('settings.providers.page.actions.disconnecting') : t('settings.providers.page.actions.disconnect')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -994,7 +997,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1 flex items-center justify-between gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Available Models
|
||||
{t('settings.providers.page.models.title')}
|
||||
{providerModels.length > 0 && (
|
||||
<span className="ml-1.5 typography-micro text-muted-foreground font-normal">
|
||||
({providerModels.length})
|
||||
@@ -1013,7 +1016,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
hideAllModels(selectedProvider.id, allIds);
|
||||
}}
|
||||
>
|
||||
Hide all
|
||||
{t('settings.providers.page.actions.hideAll')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -1021,7 +1024,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
className="!font-normal"
|
||||
onClick={() => showAllModels(selectedProvider.id)}
|
||||
>
|
||||
Show all
|
||||
{t('settings.providers.page.actions.showAll')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1032,13 +1035,13 @@ export const ProvidersPage: React.FC = () => {
|
||||
<Input
|
||||
value={modelQuery}
|
||||
onChange={(event) => setModelQuery(event.target.value)}
|
||||
placeholder="Filter models..."
|
||||
placeholder={t('settings.providers.page.models.filterPlaceholder')}
|
||||
className="h-7 pl-8 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filteredModels.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground py-4 text-center">No models match this filter.</p>
|
||||
<p className="typography-meta text-muted-foreground py-4 text-center">{t('settings.providers.page.models.noModelsMatchFilter')}</p>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--surface-subtle)]">
|
||||
{filteredModels.map((model) => {
|
||||
@@ -1053,9 +1056,9 @@ export const ProvidersPage: React.FC = () => {
|
||||
const outputTokens = formatTokens(metadata?.limit?.output);
|
||||
|
||||
const capabilityIcons: Array<{ key: string; icon: typeof RiToolsLine; label: string }> = [];
|
||||
if (metadata?.tool_call) capabilityIcons.push({ key: 'tools', icon: RiToolsLine, label: 'Tool calling' });
|
||||
if (metadata?.reasoning) capabilityIcons.push({ key: 'reasoning', icon: RiBrainAi3Line, label: 'Reasoning' });
|
||||
if (metadata?.attachment) capabilityIcons.push({ key: 'image', icon: RiFileImageLine, label: 'Image input' });
|
||||
if (metadata?.tool_call) capabilityIcons.push({ key: 'tools', icon: RiToolsLine, label: t('settings.providers.page.models.capability.toolCalling') });
|
||||
if (metadata?.reasoning) capabilityIcons.push({ key: 'reasoning', icon: RiBrainAi3Line, label: t('settings.providers.page.models.capability.reasoning') });
|
||||
if (metadata?.attachment) capabilityIcons.push({ key: 'image', icon: RiFileImageLine, label: t('settings.providers.page.models.capability.imageInput') });
|
||||
|
||||
return (
|
||||
<div key={modelId} className="py-1.5">
|
||||
@@ -1071,9 +1074,9 @@ export const ProvidersPage: React.FC = () => {
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{(contextTokens || outputTokens) && (
|
||||
<span className="typography-micro text-muted-foreground flex-shrink-0 bg-[var(--surface-muted)] px-1.5 py-0.5 rounded">
|
||||
{contextTokens ? `${contextTokens} ctx` : ''}
|
||||
{contextTokens ? `${contextTokens} ${t('settings.providers.page.models.tokenBadge.context')}` : ''}
|
||||
{contextTokens && outputTokens ? ' · ' : ''}
|
||||
{outputTokens ? `${outputTokens} out` : ''}
|
||||
{outputTokens ? `${outputTokens} ${t('settings.providers.page.models.tokenBadge.output')}` : ''}
|
||||
</span>
|
||||
)}
|
||||
{capabilityIcons.length > 0 && (
|
||||
@@ -1094,8 +1097,8 @@ export const ProvidersPage: React.FC = () => {
|
||||
type="button"
|
||||
onClick={() => toggleHiddenModel(selectedProvider.id, modelId)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-[var(--interactive-hover)]/50"
|
||||
title={isHidden ? 'Show model in selectors' : 'Hide model from selectors'}
|
||||
aria-label={isHidden ? 'Show model' : 'Hide model'}
|
||||
title={isHidden ? t('settings.providers.page.models.actions.showModelInSelectors') : t('settings.providers.page.models.actions.hideModelFromSelectors')}
|
||||
aria-label={isHidden ? t('settings.providers.page.models.actions.showModel') : t('settings.providers.page.models.actions.hideModel')}
|
||||
>
|
||||
{isHidden ? <RiEyeOffLine className="h-3.5 w-3.5" /> : <RiEyeLine className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { RiAddLine, RiStackLine } from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const ADD_PROVIDER_ID = '__add_provider__';
|
||||
|
||||
@@ -36,6 +37,7 @@ interface ProvidersSidebarProps {
|
||||
}
|
||||
|
||||
export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
const selectedProviderId = useConfigStore((state) => state.selectedProviderId);
|
||||
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
|
||||
@@ -106,10 +108,10 @@ export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">Providers</h2>
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.providers.sidebar.title')}</h2>
|
||||
<SettingsProjectSelector className="mb-3" />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">Total {providers.length}</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.providers.sidebar.total', { count: providers.length })}</span>
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
|
||||
@@ -117,8 +119,8 @@ export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect
|
||||
setSelectedProvider(ADD_PROVIDER_ID);
|
||||
onItemSelect?.();
|
||||
}}
|
||||
aria-label="Connect provider"
|
||||
title="Connect provider"
|
||||
aria-label={t('settings.providers.sidebar.actions.connectProviderAria')}
|
||||
title={t('settings.providers.sidebar.actions.connectProviderTitle')}
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -129,15 +131,15 @@ export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect
|
||||
{providers.length === 0 ? (
|
||||
<div className="py-12 px-4 text-center text-muted-foreground">
|
||||
<RiStackLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
||||
<p className="typography-ui-label font-medium">No providers found</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Check your OpenCode configuration</p>
|
||||
<p className="typography-ui-label font-medium">{t('settings.providers.sidebar.empty.title')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.providers.sidebar.empty.description')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{userProviders.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
User Providers
|
||||
{t('settings.providers.sidebar.section.userProviders')}
|
||||
</div>
|
||||
{userProviders.map((provider) => (
|
||||
<ProviderListItem
|
||||
@@ -156,7 +158,7 @@ export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect
|
||||
{projectProviders.length > 0 && (
|
||||
<>
|
||||
<div className={cn('px-2 pb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground', userProviders.length > 0 ? 'pt-3' : 'pt-2')}>
|
||||
Project Providers
|
||||
{t('settings.providers.sidebar.section.projectProviders')}
|
||||
</div>
|
||||
{projectProviders.map((provider) => (
|
||||
<ProviderListItem
|
||||
|
||||
@@ -41,6 +41,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { toast } from '@/components/ui';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { useI18n, type I18nKey } from '@/lib/i18n';
|
||||
import {
|
||||
desktopSshLogsClear,
|
||||
desktopSshLogs,
|
||||
@@ -58,34 +59,34 @@ const isPortInUseError = (error: unknown): boolean => {
|
||||
return message.includes('address already in use') || message.includes('eaddrinuse') || message.includes('port already in use');
|
||||
};
|
||||
|
||||
const phaseLabel = (phase?: string): string => {
|
||||
const phaseLabelKey = (phase?: string): I18nKey => {
|
||||
switch (phase) {
|
||||
case 'config_resolved':
|
||||
return 'Resolving configuration';
|
||||
return 'settings.remoteInstances.page.phase.resolvingConfiguration';
|
||||
case 'auth_check':
|
||||
return 'Checking auth';
|
||||
return 'settings.remoteInstances.page.phase.checkingAuth';
|
||||
case 'master_connecting':
|
||||
return 'Establishing SSH';
|
||||
return 'settings.remoteInstances.page.phase.establishingSsh';
|
||||
case 'remote_probe':
|
||||
return 'Probing remote';
|
||||
return 'settings.remoteInstances.page.phase.probingRemote';
|
||||
case 'installing':
|
||||
return 'Installing OpenChamber';
|
||||
return 'settings.remoteInstances.page.phase.installingOpenChamber';
|
||||
case 'updating':
|
||||
return 'Updating OpenChamber';
|
||||
return 'settings.remoteInstances.page.phase.updatingOpenChamber';
|
||||
case 'server_detecting':
|
||||
return 'Detecting server';
|
||||
return 'settings.remoteInstances.page.phase.detectingServer';
|
||||
case 'server_starting':
|
||||
return 'Starting server';
|
||||
return 'settings.remoteInstances.page.phase.startingServer';
|
||||
case 'forwarding':
|
||||
return 'Forwarding ports';
|
||||
return 'settings.remoteInstances.page.phase.forwardingPorts';
|
||||
case 'ready':
|
||||
return 'Ready';
|
||||
return 'settings.remoteInstances.sidebar.phase.ready';
|
||||
case 'degraded':
|
||||
return 'Reconnecting';
|
||||
return 'settings.remoteInstances.page.phase.reconnecting';
|
||||
case 'error':
|
||||
return 'Error';
|
||||
return 'settings.remoteInstances.sidebar.phase.error';
|
||||
default:
|
||||
return 'Idle';
|
||||
return 'settings.remoteInstances.sidebar.phase.idle';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -161,14 +162,14 @@ const HintLabel: React.FC<{ label: string; hint: React.ReactNode }> = ({ label,
|
||||
);
|
||||
};
|
||||
|
||||
const forwardTypeDescription = (type: DesktopSshPortForwardType): string => {
|
||||
const forwardTypeDescriptionKey = (type: DesktopSshPortForwardType): I18nKey => {
|
||||
switch (type) {
|
||||
case 'remote':
|
||||
return 'Remote (-R): expose a port on the remote machine and send that traffic back to this laptop.';
|
||||
return 'settings.remoteInstances.page.forwardTypeDescription.remote';
|
||||
case 'dynamic':
|
||||
return 'Dynamic (-D): create a local SOCKS5 proxy on this laptop (for apps that support SOCKS proxy settings).';
|
||||
return 'settings.remoteInstances.page.forwardTypeDescription.dynamic';
|
||||
default:
|
||||
return 'Local (-L): open a port on this laptop and send it to a remote host:port over SSH (use this to access remote services locally).';
|
||||
return 'settings.remoteInstances.page.forwardTypeDescription.local';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -254,6 +255,7 @@ const normalizeForSave = (instance: DesktopSshInstance): DesktopSshInstance => {
|
||||
};
|
||||
|
||||
export const RemoteInstancesPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const instances = useDesktopSshStore((state) => state.instances);
|
||||
const statusesById = useDesktopSshStore((state) => state.statusesById);
|
||||
const importCandidates = useDesktopSshStore((state) => state.importCandidates);
|
||||
@@ -379,13 +381,13 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
const normalized = normalizeForSave(draft);
|
||||
|
||||
if (!normalized.sshCommand.trim()) {
|
||||
toast.error('SSH command is required');
|
||||
toast.error(t('settings.remoteInstances.page.toast.sshCommandRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalized.localForward.bindHost === '0.0.0.0') {
|
||||
const allow = window.confirm(
|
||||
'Binding local forwards to 0.0.0.0 makes the forwarded port reachable from other devices on your network. Continue?',
|
||||
t('settings.remoteInstances.page.confirm.bindAllInterfaces'),
|
||||
);
|
||||
if (!allow) {
|
||||
return;
|
||||
@@ -397,7 +399,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
normalized.auth.sshPassword.value?.trim() &&
|
||||
normalized.auth.sshPassword.store !== 'settings'
|
||||
) {
|
||||
const store = window.confirm('Store SSH password in settings.json as plaintext?');
|
||||
const store = window.confirm(t('settings.remoteInstances.page.confirm.storeSshPasswordPlaintext'));
|
||||
normalized.auth.sshPassword.store = store ? 'settings' : 'never';
|
||||
if (!store) {
|
||||
normalized.auth.sshPassword.value = undefined;
|
||||
@@ -409,7 +411,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
normalized.auth.openchamberPassword.value?.trim() &&
|
||||
normalized.auth.openchamberPassword.store !== 'settings'
|
||||
) {
|
||||
const store = window.confirm('Store OpenChamber UI password in settings.json as plaintext?');
|
||||
const store = window.confirm(t('settings.remoteInstances.page.confirm.storeUiPasswordPlaintext'));
|
||||
normalized.auth.openchamberPassword.store = store ? 'settings' : 'never';
|
||||
if (!store) {
|
||||
normalized.auth.openchamberPassword.value = undefined;
|
||||
@@ -418,13 +420,13 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
|
||||
try {
|
||||
await upsertInstance(normalized);
|
||||
toast.success('SSH instance saved');
|
||||
toast.success(t('settings.remoteInstances.page.toast.instanceSaved'));
|
||||
} catch (error) {
|
||||
toast.error('Failed to save SSH instance', {
|
||||
toast.error(t('settings.remoteInstances.page.toast.saveFailed'), {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}, [draft, upsertInstance]);
|
||||
}, [draft, t, upsertInstance]);
|
||||
|
||||
const createImportedInstance = React.useCallback(
|
||||
async (host: string, destination: string): Promise<boolean> => {
|
||||
@@ -432,10 +434,10 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
try {
|
||||
await createFromCommand(id, `ssh ${destination}`, host);
|
||||
setSelectedId(id);
|
||||
toast.success('SSH instance created');
|
||||
toast.success(t('settings.remoteInstances.page.toast.instanceCreated'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error('Failed to create SSH instance', {
|
||||
toast.error(t('settings.remoteInstances.sidebar.toast.createFailed'), {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return false;
|
||||
@@ -471,7 +473,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
if (!destination) {
|
||||
toast.error('Destination is required');
|
||||
toast.error(t('settings.remoteInstances.page.toast.destinationRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -485,7 +487,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
} finally {
|
||||
setPatternCreating(false);
|
||||
}
|
||||
}, [createImportedInstance, patternDestination, patternHost]);
|
||||
}, [createImportedInstance, patternDestination, patternHost, t]);
|
||||
|
||||
const connectWithPortRecovery = React.useCallback(async () => {
|
||||
if (!selectedInstance) return;
|
||||
@@ -497,7 +499,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const allow = window.confirm('Local port is already in use. Pick a random free local port and retry?');
|
||||
const allow = window.confirm(t('settings.remoteInstances.sidebar.confirm.localPortInUseRetry'));
|
||||
if (!allow) {
|
||||
throw error;
|
||||
}
|
||||
@@ -512,9 +514,9 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
|
||||
await upsertInstance(nextInstance);
|
||||
await connect(nextInstance.id);
|
||||
toast.success('Retried with a random local port');
|
||||
toast.success(t('settings.remoteInstances.sidebar.toast.retriedWithRandomPort'));
|
||||
}
|
||||
}, [connect, selectedInstance, upsertInstance]);
|
||||
}, [connect, selectedInstance, t, upsertInstance]);
|
||||
|
||||
const readLogsForInstance = React.useCallback(async (id: string) => {
|
||||
const lines = await desktopSshLogs(id, 600);
|
||||
@@ -576,15 +578,15 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
|
||||
const handleCopyAllLogs = React.useCallback(() => {
|
||||
if (!logLinesText.trim()) {
|
||||
toast.error('No logs to copy');
|
||||
toast.error(t('settings.remoteInstances.page.toast.noLogsToCopy'));
|
||||
return;
|
||||
}
|
||||
void copyTextToClipboard(logLinesText).then((result) => {
|
||||
if (result.ok) {
|
||||
toast.success('Logs copied');
|
||||
toast.success(t('settings.remoteInstances.page.toast.logsCopied'));
|
||||
}
|
||||
});
|
||||
}, [logLinesText]);
|
||||
}, [logLinesText, t]);
|
||||
|
||||
const handleClearLogs = React.useCallback(async () => {
|
||||
if (!draft) {
|
||||
@@ -593,28 +595,28 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
try {
|
||||
await desktopSshLogsClear(draft.id);
|
||||
setLogDialogLines([]);
|
||||
toast.success('Logs cleared');
|
||||
toast.success(t('settings.remoteInstances.page.toast.logsCleared'));
|
||||
} catch (error) {
|
||||
toast.error('Failed to clear logs', {
|
||||
toast.error(t('settings.remoteInstances.page.toast.clearLogsFailed'), {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}, [draft]);
|
||||
}, [draft, t]);
|
||||
|
||||
const handleOpenCurrentInstance = React.useCallback(async () => {
|
||||
if (!status?.localUrl) {
|
||||
toast.error('Instance URL is not available yet');
|
||||
toast.error(t('settings.remoteInstances.page.toast.instanceUrlUnavailable'));
|
||||
return;
|
||||
}
|
||||
|
||||
const target = status.localUrl.trim();
|
||||
if (!target) {
|
||||
toast.error('Instance URL is not available yet');
|
||||
toast.error(t('settings.remoteInstances.page.toast.instanceUrlUnavailable'));
|
||||
return;
|
||||
}
|
||||
|
||||
navigateToUrl(target);
|
||||
}, [status?.localUrl]);
|
||||
}, [status?.localUrl, t]);
|
||||
|
||||
const handlePrimaryConnectionAction = React.useCallback(() => {
|
||||
if (!draft) {
|
||||
@@ -625,15 +627,19 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
const operation = canDisconnect ? disconnect(draft.id) : connectWithPortRecovery();
|
||||
void operation
|
||||
.catch((error) => {
|
||||
const actionLabel = canDisconnect ? (isReady ? 'disconnect' : 'cancel connection') : 'connect';
|
||||
toast.error(`Failed to ${actionLabel}`, {
|
||||
const key = canDisconnect
|
||||
? (isReady
|
||||
? 'settings.remoteInstances.page.toast.disconnectFailed'
|
||||
: 'settings.remoteInstances.page.toast.cancelConnectionFailed')
|
||||
: 'settings.remoteInstances.page.toast.connectFailed';
|
||||
toast.error(t(key), {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsPrimaryActionPending(false);
|
||||
});
|
||||
}, [canDisconnect, connectWithPortRecovery, disconnect, draft, isReady]);
|
||||
}, [canDisconnect, connectWithPortRecovery, disconnect, draft, isReady, t]);
|
||||
|
||||
const handleRetryAction = React.useCallback(() => {
|
||||
if (!draft) {
|
||||
@@ -651,22 +657,22 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
|
||||
void operation
|
||||
.catch((error) => {
|
||||
toast.error('Retry failed', {
|
||||
toast.error(t('settings.remoteInstances.page.toast.retryFailed'), {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsRetryPending(false);
|
||||
});
|
||||
}, [connectWithPortRecovery, disconnect, draft, isConnecting, isReconnecting, retry]);
|
||||
}, [connectWithPortRecovery, disconnect, draft, isConnecting, isReconnecting, retry, t]);
|
||||
|
||||
const retryButtonLabel = isConnecting
|
||||
? 'Connecting...'
|
||||
? t('settings.remoteInstances.page.actions.connecting')
|
||||
: isReconnecting
|
||||
? reconnectAppearsStuck
|
||||
? 'Reconnect now'
|
||||
: 'Reconnecting...'
|
||||
: 'Retry';
|
||||
? t('settings.remoteInstances.page.actions.reconnectNow')
|
||||
: t('settings.remoteInstances.page.actions.reconnecting')
|
||||
: t('settings.remoteInstances.sidebar.actions.retry');
|
||||
|
||||
const canRetry =
|
||||
!isPrimaryActionPending &&
|
||||
@@ -674,30 +680,34 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
(statusPhase === 'error' || statusPhase === 'idle' || !statusPhase || (isReconnecting && reconnectAppearsStuck)) &&
|
||||
!isConnecting;
|
||||
|
||||
const primaryButtonLabel = isReady ? 'Disconnect' : canDisconnect ? 'Cancel' : 'Connect';
|
||||
const primaryButtonLabel = isReady
|
||||
? t('settings.remoteInstances.sidebar.actions.disconnect')
|
||||
: canDisconnect
|
||||
? t('settings.remoteInstances.page.actions.cancel')
|
||||
: t('settings.remoteInstances.sidebar.actions.connect');
|
||||
|
||||
if (!draft) {
|
||||
return (
|
||||
<SettingsPageLayout>
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Remote Instances</h3>
|
||||
<p className="typography-meta text-muted-foreground">Manage SSH-backed OpenChamber instances.</p>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.title')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.description')}</p>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-3">
|
||||
<p className="typography-meta text-muted-foreground">Select an instance from the sidebar or import one from SSH config.</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.empty.selectInstance')}</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Import from SSH config</h3>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.import.sectionTitle')}</h3>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
{isImportsLoading ? (
|
||||
<p className="typography-meta text-muted-foreground">Loading SSH hosts...</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
|
||||
) : importCandidates.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">No SSH config hosts found.</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.noneFound')}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{importCandidates.map((candidate) => (
|
||||
@@ -705,7 +715,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
<div className="min-w-0">
|
||||
<div className="typography-ui-label text-foreground truncate">
|
||||
{candidate.host}
|
||||
{candidate.pattern ? ' (pattern)' : ''}
|
||||
{candidate.pattern ? ` ${t('settings.remoteInstances.page.import.patternSuffix')}` : ''}
|
||||
</div>
|
||||
<div className="typography-micro text-muted-foreground">{candidate.source} config</div>
|
||||
</div>
|
||||
@@ -716,7 +726,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
className="!font-normal"
|
||||
onClick={() => void handleImportCandidate(candidate.host, candidate.pattern)}
|
||||
>
|
||||
Create
|
||||
{t('settings.remoteInstances.page.actions.create')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
@@ -735,9 +745,9 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create from wildcard pattern</DialogTitle>
|
||||
<DialogTitle>{t('settings.remoteInstances.page.patternDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{patternHost ? `${patternHost} requires a concrete destination.` : 'Enter destination.'}
|
||||
{patternHost ? t('settings.remoteInstances.page.patternDialog.descriptionWithHost', { host: patternHost }) : t('settings.remoteInstances.page.patternDialog.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
@@ -750,15 +760,15 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
<Input
|
||||
value={patternDestination}
|
||||
onChange={(event) => setPatternDestination(event.target.value)}
|
||||
placeholder="user@host"
|
||||
placeholder={t('settings.remoteInstances.page.patternDialog.destinationPlaceholder')}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={closePatternDialog} disabled={patternCreating}>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" size="xs" className="!font-normal" disabled={patternCreating}>
|
||||
Create
|
||||
{t('settings.remoteInstances.page.actions.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -777,16 +787,16 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
<h2 className="typography-ui-header font-semibold text-foreground truncate">{instanceTitle}</h2>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 typography-meta text-muted-foreground">
|
||||
<span className={`h-2.5 w-2.5 rounded-full ${phaseDotClass(statusPhase)}`} />
|
||||
<span>{phaseLabel(statusPhase)}</span>
|
||||
<span>{t(phaseLabelKey(statusPhase))}</span>
|
||||
{status?.localUrl ? <span className="font-mono text-foreground/80">{status.localUrl}</span> : null}
|
||||
{reconnectAppearsStuck ? <span>reconnect stale</span> : null}
|
||||
{reconnectAppearsStuck ? <span>{t('settings.remoteInstances.page.status.reconnectStale')}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Actions</h3>
|
||||
<p className="typography-meta text-muted-foreground">Connect, inspect logs, and manage this instance.</p>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.actions')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.actionsDescription')}</p>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -822,7 +832,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<RiTerminalWindowLine className="h-3.5 w-3.5" />
|
||||
Logs
|
||||
{t('settings.remoteInstances.page.actions.logs')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -830,27 +840,27 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
size="xs"
|
||||
className="!font-normal text-[var(--status-error)] border-[var(--status-error)]/30 hover:text-[var(--status-error)]"
|
||||
onClick={() => {
|
||||
const ok = window.confirm('Remove this SSH instance?');
|
||||
const ok = window.confirm(t('settings.remoteInstances.page.confirm.removeInstance'));
|
||||
if (!ok) return;
|
||||
void removeInstance(draft.id)
|
||||
.then(() => {
|
||||
setSelectedId(null);
|
||||
toast.success('SSH instance removed');
|
||||
toast.success(t('settings.remoteInstances.page.toast.instanceRemoved'));
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('Failed to remove SSH instance', {
|
||||
toast.error(t('settings.remoteInstances.page.toast.removeInstanceFailed'), {
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
Remove
|
||||
{t('settings.remoteInstances.sidebar.actions.remove')}
|
||||
</Button>
|
||||
</div>
|
||||
{status?.localUrl ? (
|
||||
<div className="flex flex-wrap items-center gap-2 typography-meta text-muted-foreground">
|
||||
<span>Current local URL:</span>
|
||||
<span>{t('settings.remoteInstances.page.status.currentLocalUrl')}</span>
|
||||
<span className="font-mono text-foreground/90">{status.localUrl}</span>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -859,12 +869,12 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Instance</h3>
|
||||
<p className="typography-meta text-muted-foreground">Core SSH settings.</p>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.instance')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.instanceDescription')}</p>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-3">
|
||||
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
|
||||
<span className="typography-ui-label text-foreground w-56 shrink-0">SSH command</span>
|
||||
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.sshCommand')}</span>
|
||||
<Input
|
||||
className="h-7 md:max-w-xl"
|
||||
value={draft.sshCommand}
|
||||
@@ -874,11 +884,11 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
sshCommand: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="ssh -J jump user@host"
|
||||
placeholder={t('settings.remoteInstances.page.field.sshCommandPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
|
||||
<span className="typography-ui-label text-foreground w-56 shrink-0">Nickname</span>
|
||||
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.nickname')}</span>
|
||||
<Input
|
||||
className="h-7 md:max-w-sm"
|
||||
value={draft.nickname || ''}
|
||||
@@ -888,11 +898,11 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
nickname: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Production Host"
|
||||
placeholder={t('settings.remoteInstances.page.field.nicknamePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
|
||||
<span className="typography-ui-label text-foreground w-56 shrink-0">Connection timeout (sec)</span>
|
||||
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.connectionTimeoutSeconds')}</span>
|
||||
<NumberInput
|
||||
containerClassName="w-fit"
|
||||
min={5}
|
||||
@@ -913,16 +923,16 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
|
||||
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Remote server</h3>
|
||||
<p className="typography-meta text-muted-foreground">How OpenChamber is discovered or started on the remote machine.</p>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.remoteServer')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.remoteServerDescription')}</p>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-3">
|
||||
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
|
||||
<div className="w-56 shrink-0">
|
||||
<HintLabel
|
||||
label="Mode"
|
||||
hint="Managed installs/updates and starts OpenChamber remotely. External assumes it is already running."
|
||||
/>
|
||||
<HintLabel
|
||||
label={t('settings.remoteInstances.page.field.mode')}
|
||||
hint={t('settings.remoteInstances.page.field.modeHint')}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={draft.remoteOpenchamber.mode}
|
||||
@@ -937,21 +947,21 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-fit min-w-[140px]">
|
||||
<SelectValue placeholder="Select mode" />
|
||||
<SelectValue placeholder={t('settings.remoteInstances.page.field.modePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="managed">Managed (auto start)</SelectItem>
|
||||
<SelectItem value="external">External (already running)</SelectItem>
|
||||
<SelectItem value="managed">{t('settings.remoteInstances.page.field.modeManaged')}</SelectItem>
|
||||
<SelectItem value="external">{t('settings.remoteInstances.page.field.modeExternal')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
|
||||
<div className="w-56 shrink-0">
|
||||
<HintLabel
|
||||
label="Preferred remote port"
|
||||
hint="Port OpenChamber should use on the remote host. Leave empty to let the runtime choose."
|
||||
/>
|
||||
<HintLabel
|
||||
label={t('settings.remoteInstances.page.field.preferredRemotePort')}
|
||||
hint={t('settings.remoteInstances.page.field.preferredRemotePortHint')}
|
||||
/>
|
||||
</div>
|
||||
<NumberInput
|
||||
containerClassName="w-fit"
|
||||
@@ -978,7 +988,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
},
|
||||
}));
|
||||
}}
|
||||
emptyLabel="Auto"
|
||||
emptyLabel={t('settings.remoteInstances.page.field.auto')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -986,8 +996,8 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
|
||||
<div className="w-56 shrink-0">
|
||||
<HintLabel
|
||||
label="Install method"
|
||||
hint="How OpenChamber gets installed/updated remotely when mode is Managed."
|
||||
label={t('settings.remoteInstances.page.field.installMethod')}
|
||||
hint={t('settings.remoteInstances.page.field.installMethodHint')}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
@@ -1006,13 +1016,13 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-fit min-w-[140px]">
|
||||
<SelectValue placeholder="Select install method" />
|
||||
<SelectValue placeholder={t('settings.remoteInstances.page.field.selectInstallMethodPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="bun">bun</SelectItem>
|
||||
<SelectItem value="npm">npm</SelectItem>
|
||||
<SelectItem value="download_release">download release</SelectItem>
|
||||
<SelectItem value="upload_bundle">upload bundle</SelectItem>
|
||||
<SelectItem value="download_release">{t('settings.remoteInstances.page.field.installMethodDownloadRelease')}</SelectItem>
|
||||
<SelectItem value="upload_bundle">{t('settings.remoteInstances.page.field.installMethodUploadBundle')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1022,8 +1032,8 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
|
||||
<div className="w-56 shrink-0">
|
||||
<HintLabel
|
||||
label="Keep server running"
|
||||
hint="If enabled, OpenChamber daemon is left running remotely when you disconnect."
|
||||
label={t('settings.remoteInstances.page.field.keepServerRunning')}
|
||||
hint={t('settings.remoteInstances.page.field.keepServerRunningHint')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-2 md:max-w-xs">
|
||||
@@ -1047,23 +1057,23 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
|
||||
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Main tunnel</h3>
|
||||
<p className="typography-meta text-muted-foreground">Primary local URL that points to the remote OpenChamber server.</p>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.mainTunnel')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.mainTunnelDescription')}</p>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-3">
|
||||
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
|
||||
<div className="w-56 shrink-0">
|
||||
<HintLabel
|
||||
label="Bind host"
|
||||
hint="Network interface for the main local URL. Use 127.0.0.1/localhost for local-only access."
|
||||
/>
|
||||
<HintLabel
|
||||
label={t('settings.remoteInstances.page.field.bindHost')}
|
||||
hint={t('settings.remoteInstances.page.field.bindHostHint')}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={draft.localForward.bindHost}
|
||||
onValueChange={(value) => {
|
||||
if (value === '0.0.0.0') {
|
||||
const allow = window.confirm(
|
||||
'Binding to 0.0.0.0 exposes forwarded ports to your local network. Continue?',
|
||||
t('settings.remoteInstances.page.confirm.bindAllInterfaces'),
|
||||
);
|
||||
if (!allow) return;
|
||||
}
|
||||
@@ -1077,7 +1087,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-fit min-w-[140px]">
|
||||
<SelectValue placeholder="Select bind host" />
|
||||
<SelectValue placeholder={t('settings.remoteInstances.page.field.selectBindHostPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="127.0.0.1">127.0.0.1</SelectItem>
|
||||
@@ -1089,10 +1099,10 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
|
||||
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
|
||||
<div className="w-56 shrink-0">
|
||||
<HintLabel
|
||||
label="Preferred local port"
|
||||
hint="Preferred local port for the main OpenChamber tunnel. Leave empty for auto-select."
|
||||
/>
|
||||
<HintLabel
|
||||
label={t('settings.remoteInstances.page.field.preferredLocalPort')}
|
||||
hint={t('settings.remoteInstances.page.field.preferredLocalPortHint')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-2 md:max-w-sm">
|
||||
<NumberInput
|
||||
@@ -1120,14 +1130,14 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
},
|
||||
}));
|
||||
}}
|
||||
emptyLabel="Auto"
|
||||
emptyLabel={t('settings.remoteInstances.page.field.auto')}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal h-7 w-7 px-0"
|
||||
title="Pick random port"
|
||||
title={t('settings.remoteInstances.page.actions.pickRandomPort')}
|
||||
onClick={() =>
|
||||
updateDraft((current) => ({
|
||||
...current,
|
||||
@@ -1147,12 +1157,12 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
|
||||
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Authentication</h3>
|
||||
<p className="typography-meta text-muted-foreground">Optional credentials for SSH and remote UI.</p>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.authentication')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.authenticationDescription')}</p>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-3">
|
||||
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
|
||||
<span className="typography-ui-label text-foreground w-56 shrink-0">SSH password (optional)</span>
|
||||
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.sshPasswordOptional')}</span>
|
||||
<Input
|
||||
className="h-7 md:max-w-sm"
|
||||
type="password"
|
||||
@@ -1170,12 +1180,12 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
},
|
||||
}))
|
||||
}
|
||||
placeholder="Password or key passphrase"
|
||||
placeholder={t('settings.remoteInstances.page.field.sshPasswordPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
|
||||
<span className="typography-ui-label text-foreground w-56 shrink-0">OpenChamber UI password (optional)</span>
|
||||
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.uiPasswordOptional')}</span>
|
||||
<Input
|
||||
className="h-7 md:max-w-sm"
|
||||
type="password"
|
||||
@@ -1193,7 +1203,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
},
|
||||
}))
|
||||
}
|
||||
placeholder="Protect remote UI with password"
|
||||
placeholder={t('settings.remoteInstances.page.field.uiPasswordPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1201,12 +1211,12 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
|
||||
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Port Forwards</h3>
|
||||
<p className="typography-meta text-muted-foreground">Optional extra SSH forwards in addition to the primary OpenChamber tunnel.</p>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.portForwards')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.portForwardsDescription')}</p>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-2">
|
||||
{draft.portForwards.length === 0 ? (
|
||||
<p className="typography-micro text-muted-foreground/80">No extra forwards configured yet.</p>
|
||||
<p className="typography-micro text-muted-foreground/80">{t('settings.remoteInstances.page.empty.noExtraForwards')}</p>
|
||||
) : null}
|
||||
|
||||
{draft.portForwards.map((forward, index) => {
|
||||
@@ -1238,7 +1248,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
|
||||
const isForwardOpen = Boolean(expandedForwards[forward.id]);
|
||||
|
||||
const typeLabel = forward.type === 'local' ? 'Local (-L)' : forward.type === 'remote' ? 'Remote (-R)' : 'Dynamic (-D)';
|
||||
const typeLabel = forward.type === 'local' ? t('settings.remoteInstances.page.forwardType.local') : forward.type === 'remote' ? t('settings.remoteInstances.page.forwardType.remote') : t('settings.remoteInstances.page.forwardType.dynamic');
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
@@ -1261,7 +1271,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={forward.enabled} onCheckedChange={(checked) => updateForward((item) => ({ ...item, enabled: checked }))} aria-label="Enable forward" />
|
||||
<Switch checked={forward.enabled} onCheckedChange={(checked) => updateForward((item) => ({ ...item, enabled: checked }))} aria-label={t('settings.remoteInstances.page.actions.enableForwardAria')} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -1280,12 +1290,12 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
</div>
|
||||
<CollapsibleContent className="pt-2">
|
||||
<div className="space-y-0 pb-2">
|
||||
<p className="typography-meta text-muted-foreground mb-3">{forwardTypeDescription(forward.type)}</p>
|
||||
<p className="typography-meta text-muted-foreground mb-3">{t(forwardTypeDescriptionKey(forward.type))}</p>
|
||||
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
|
||||
<div className="w-56 shrink-0">
|
||||
<HintLabel
|
||||
label="Forward type"
|
||||
hint="Local (-L): laptop -> remote service. Remote (-R): remote machine -> this laptop. Dynamic (-D): local SOCKS5 proxy."
|
||||
label={t('settings.remoteInstances.page.field.forwardType')}
|
||||
hint={t('settings.remoteInstances.page.field.forwardTypeHint')}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
@@ -1298,12 +1308,12 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-fit min-w-[140px]">
|
||||
<SelectValue placeholder="Type" />
|
||||
<SelectValue placeholder={t('settings.remoteInstances.page.field.typePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="local">Local (-L)</SelectItem>
|
||||
<SelectItem value="remote">Remote (-R)</SelectItem>
|
||||
<SelectItem value="dynamic">Dynamic (-D)</SelectItem>
|
||||
<SelectItem value="local">{t('settings.remoteInstances.page.forwardType.local')}</SelectItem>
|
||||
<SelectItem value="remote">{t('settings.remoteInstances.page.forwardType.remote')}</SelectItem>
|
||||
<SelectItem value="dynamic">{t('settings.remoteInstances.page.forwardType.dynamic')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1322,7 +1332,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
localHost: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="127.0.0.1"
|
||||
placeholder={t('settings.remoteInstances.page.field.localHostPlaceholder')}
|
||||
/>
|
||||
<span className="text-muted-foreground">:</span>
|
||||
<NumberInput
|
||||
@@ -1344,7 +1354,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
localPort: undefined,
|
||||
}));
|
||||
}}
|
||||
emptyLabel="Auto"
|
||||
emptyLabel={t('settings.remoteInstances.page.field.auto')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1364,7 +1374,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
remoteHost: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="127.0.0.1"
|
||||
placeholder={t('settings.remoteInstances.page.field.remoteHostPlaceholder')}
|
||||
/>
|
||||
<span className="text-muted-foreground">:</span>
|
||||
<NumberInput
|
||||
@@ -1386,7 +1396,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
remotePort: undefined,
|
||||
}));
|
||||
}}
|
||||
emptyLabel="Auto"
|
||||
emptyLabel={t('settings.remoteInstances.page.field.auto')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1398,27 +1408,27 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
<>
|
||||
<RiComputerLine className="h-3.5 w-3.5" />
|
||||
<span className="font-mono text-foreground">{localEndpoint}</span>
|
||||
<span>(local SOCKS5)</span>
|
||||
<span>{t('settings.remoteInstances.page.preview.localSocks5')}</span>
|
||||
</>
|
||||
) : forward.type === 'remote' ? (
|
||||
<>
|
||||
<RiServerLine className="h-3.5 w-3.5" />
|
||||
<span className="font-mono text-foreground">{remoteEndpoint}</span>
|
||||
<span>(remote)</span>
|
||||
<span>{t('settings.remoteInstances.page.preview.remote')}</span>
|
||||
<RiArrowRightLine className="h-3.5 w-3.5" />
|
||||
<RiComputerLine className="h-3.5 w-3.5" />
|
||||
<span className="font-mono text-foreground">{localEndpoint}</span>
|
||||
<span>(local)</span>
|
||||
<span>{t('settings.remoteInstances.page.preview.local')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiComputerLine className="h-3.5 w-3.5" />
|
||||
<span className="font-mono text-foreground">{localEndpoint}</span>
|
||||
<span>(local)</span>
|
||||
<span>{t('settings.remoteInstances.page.preview.local')}</span>
|
||||
<RiArrowRightLine className="h-3.5 w-3.5" />
|
||||
<RiServerLine className="h-3.5 w-3.5" />
|
||||
<span className="font-mono text-foreground">{remoteEndpoint}</span>
|
||||
<span>(remote)</span>
|
||||
<span>{t('settings.remoteInstances.page.preview.remote')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1432,13 +1442,13 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
onClick={() => {
|
||||
void openExternalUrl(localEndpointUrl).then((opened) => {
|
||||
if (!opened) {
|
||||
toast.error('Failed to open local endpoint');
|
||||
toast.error(t('settings.remoteInstances.page.toast.openLocalEndpointFailed'));
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<RiExternalLinkLine className="h-3.5 w-3.5" />
|
||||
Open local
|
||||
{t('settings.remoteInstances.page.actions.openLocal')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1466,20 +1476,20 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
Add forward
|
||||
{t('settings.remoteInstances.page.actions.addForward')}
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Import from SSH config</h3>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.import.sectionTitle')}</h3>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
{isImportsLoading ? (
|
||||
<p className="typography-meta text-muted-foreground">Loading SSH hosts...</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
|
||||
) : importCandidates.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">No SSH hosts available.</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.noneAvailable')}</p>
|
||||
) : (
|
||||
<div>
|
||||
{importCandidates.slice(0, 8).map((candidate, index) => (
|
||||
@@ -1501,7 +1511,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
className="!font-normal"
|
||||
onClick={() => void handleImportCandidate(candidate.host, candidate.pattern)}
|
||||
>
|
||||
Import
|
||||
{t('settings.common.actions.import')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
@@ -1513,7 +1523,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
<div className="sticky bottom-0 z-10 -mx-3 sm:-mx-6 bg-[var(--surface-background)] border-t border-[var(--interactive-border)] px-3 sm:px-6 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" size="xs" className="!font-normal" onClick={() => void handleSave()} disabled={!hasChanges || isSaving}>
|
||||
Save changes
|
||||
{t('settings.common.actions.saveChanges')}
|
||||
</Button>
|
||||
{status?.localUrl ? (
|
||||
<>
|
||||
@@ -1525,13 +1535,13 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
onClick={() => {
|
||||
void copyTextToClipboard(status.localUrl || '').then((result) => {
|
||||
if (result.ok) {
|
||||
toast.success('Local URL copied');
|
||||
toast.success(t('settings.remoteInstances.page.toast.localUrlCopied'));
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<RiFileCopyLine className="h-3.5 w-3.5" />
|
||||
Copy local URL
|
||||
{t('settings.remoteInstances.page.actions.copyLocalUrl')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1543,7 +1553,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<RiExternalLinkLine className="h-3.5 w-3.5" />
|
||||
Open
|
||||
{t('settings.remoteInstances.page.actions.open')}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
@@ -1554,28 +1564,28 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
<Dialog open={logDialogOpen} onOpenChange={setLogDialogOpen}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>SSH Logs</DialogTitle>
|
||||
<DialogTitle>{t('settings.remoteInstances.page.logsDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{draft?.nickname?.trim() || draft?.sshParsed?.destination || draft?.id || 'Selected instance'}
|
||||
{draft?.nickname?.trim() || draft?.sshParsed?.destination || draft?.id || t('settings.remoteInstances.page.logsDialog.selectedInstanceFallback')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={handleCopyAllLogs} disabled={logDialogLoading || !logLinesText.trim()}>
|
||||
<RiFileCopyLine className="h-3.5 w-3.5" />
|
||||
Copy all
|
||||
{t('settings.common.actions.copyAll')}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => void handleClearLogs()} disabled={logDialogLoading}>
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
Clear
|
||||
{t('settings.common.actions.clear')}
|
||||
</Button>
|
||||
</div>
|
||||
{logDialogLoading ? (
|
||||
<div className="typography-meta text-muted-foreground">Loading logs...</div>
|
||||
<div className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.logsDialog.loading')}</div>
|
||||
) : logDialogError ? (
|
||||
<div className="typography-meta text-[var(--status-error)]">{logDialogError}</div>
|
||||
) : (
|
||||
<pre className="max-h-[55vh] overflow-auto rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-3 typography-micro text-foreground whitespace-pre-wrap break-words">
|
||||
{logDialogLines.length > 0 ? logDialogLines.join('\n') : 'No SSH logs yet.'}
|
||||
{logDialogLines.length > 0 ? logDialogLines.join('\n') : t('settings.remoteInstances.page.logsDialog.empty')}
|
||||
</pre>
|
||||
)}
|
||||
</DialogContent>
|
||||
@@ -1591,9 +1601,11 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create from wildcard pattern</DialogTitle>
|
||||
<DialogTitle>{t('settings.remoteInstances.page.patternDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{patternHost ? `${patternHost} requires a concrete destination.` : 'Enter destination.'}
|
||||
{patternHost
|
||||
? t('settings.remoteInstances.page.patternDialog.descriptionWithHost', { host: patternHost })
|
||||
: t('settings.remoteInstances.page.patternDialog.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
@@ -1606,15 +1618,15 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
<Input
|
||||
value={patternDestination}
|
||||
onChange={(event) => setPatternDestination(event.target.value)}
|
||||
placeholder="user@host"
|
||||
placeholder={t('settings.remoteInstances.page.patternDialog.destinationPlaceholder')}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={closePatternDialog} disabled={patternCreating}>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" size="xs" className="!font-normal" disabled={patternCreating}>
|
||||
Create
|
||||
{t('settings.common.actions.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { toast } from '@/components/ui';
|
||||
import type { DesktopSshInstance } from '@/lib/desktopSsh';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type RemoteInstancesSidebarProps = {
|
||||
onItemSelect?: () => void;
|
||||
@@ -28,30 +29,31 @@ const isPortInUseError = (error: unknown): boolean => {
|
||||
return message.includes('address already in use') || message.includes('eaddrinuse') || message.includes('port already in use');
|
||||
};
|
||||
|
||||
const phaseLabel = (phase?: string): string => {
|
||||
const phaseLabelKey = (phase?: string) => {
|
||||
switch (phase) {
|
||||
case 'ready':
|
||||
return 'Ready';
|
||||
return 'settings.remoteInstances.sidebar.phase.ready';
|
||||
case 'error':
|
||||
return 'Error';
|
||||
return 'settings.remoteInstances.sidebar.phase.error';
|
||||
case 'degraded':
|
||||
return 'Reconnect';
|
||||
return 'settings.remoteInstances.sidebar.phase.reconnect';
|
||||
case 'installing':
|
||||
return 'Installing';
|
||||
return 'settings.remoteInstances.sidebar.phase.installing';
|
||||
case 'updating':
|
||||
return 'Updating';
|
||||
return 'settings.remoteInstances.sidebar.phase.updating';
|
||||
case 'forwarding':
|
||||
return 'Forwarding';
|
||||
return 'settings.remoteInstances.sidebar.phase.forwarding';
|
||||
case 'server_starting':
|
||||
return 'Starting';
|
||||
return 'settings.remoteInstances.sidebar.phase.starting';
|
||||
case 'master_connecting':
|
||||
return 'Connecting';
|
||||
return 'settings.remoteInstances.sidebar.phase.connecting';
|
||||
default:
|
||||
return 'Idle';
|
||||
return 'settings.remoteInstances.sidebar.phase.idle';
|
||||
}
|
||||
};
|
||||
|
||||
export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({ onItemSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const instances = useDesktopSshStore((state) => state.instances);
|
||||
const statusesById = useDesktopSshStore((state) => state.statusesById);
|
||||
const isLoading = useDesktopSshStore((state) => state.isLoading);
|
||||
@@ -89,15 +91,15 @@ export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({
|
||||
const handleAdd = React.useCallback(async () => {
|
||||
const id = makeId();
|
||||
try {
|
||||
await createFromCommand(id, 'ssh user@example.com', 'New SSH Instance');
|
||||
await createFromCommand(id, 'ssh user@example.com', t('settings.remoteInstances.sidebar.newSshInstanceName'));
|
||||
setSelectedId(id);
|
||||
onItemSelect?.();
|
||||
} catch (error) {
|
||||
toast.error('Failed to create SSH instance', {
|
||||
toast.error(t('settings.remoteInstances.sidebar.toast.createFailed'), {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}, [createFromCommand, onItemSelect, setSelectedId]);
|
||||
}, [createFromCommand, onItemSelect, setSelectedId, t]);
|
||||
|
||||
const connectWithPortRecovery = React.useCallback(async (instance: DesktopSshInstance) => {
|
||||
try {
|
||||
@@ -108,7 +110,7 @@ export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({
|
||||
throw error;
|
||||
}
|
||||
|
||||
const allow = window.confirm('Local port is already in use. Pick a random free local port and retry?');
|
||||
const allow = window.confirm(t('settings.remoteInstances.sidebar.confirm.localPortInUseRetry'));
|
||||
if (!allow) {
|
||||
throw error;
|
||||
}
|
||||
@@ -123,25 +125,25 @@ export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({
|
||||
|
||||
await upsertInstance(nextInstance);
|
||||
await connect(nextInstance.id);
|
||||
toast.success('Retried with a random local port');
|
||||
toast.success(t('settings.remoteInstances.sidebar.toast.retriedWithRandomPort'));
|
||||
}
|
||||
}, [connect, upsertInstance]);
|
||||
}, [connect, t, upsertInstance]);
|
||||
|
||||
return (
|
||||
<SettingsSidebarLayout
|
||||
variant="background"
|
||||
header={
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">Remote Instances</h2>
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.remoteInstances.sidebar.title')}</h2>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">Total {instances.length}</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.remoteInstances.sidebar.total', { count: instances.length })}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 -my-1 text-muted-foreground"
|
||||
onClick={() => void handleAdd()}
|
||||
aria-label="Add SSH instance"
|
||||
aria-label={t('settings.remoteInstances.sidebar.actions.addSshInstance')}
|
||||
>
|
||||
<RiAddLine className="size-4" />
|
||||
</Button>
|
||||
@@ -153,7 +155,7 @@ export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({
|
||||
const status = statusesById[instance.id];
|
||||
const selected = instance.id === selectedId;
|
||||
const title = instance.nickname?.trim() || instance.sshParsed?.destination || instance.id;
|
||||
const metadata = `${phaseLabel(status?.phase)}${status?.localUrl ? ` · ${status.localUrl}` : ''}`;
|
||||
const metadata = `${t(phaseLabelKey(status?.phase))}${status?.localUrl ? ` · ${status.localUrl}` : ''}`;
|
||||
const isReady = status?.phase === 'ready';
|
||||
const canRetry = status?.phase === 'error' || status?.phase === 'degraded';
|
||||
|
||||
@@ -169,31 +171,36 @@ export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({
|
||||
}}
|
||||
actions={[
|
||||
{
|
||||
label: isReady ? 'Disconnect' : 'Connect',
|
||||
label: isReady ? t('settings.remoteInstances.sidebar.actions.disconnect') : t('settings.remoteInstances.sidebar.actions.connect'),
|
||||
icon: isReady ? RiStopLine : RiPlug2Line,
|
||||
onClick: () => {
|
||||
const op = isReady ? disconnect(instance.id) : connectWithPortRecovery(instance);
|
||||
void op.catch((error) => {
|
||||
toast.error(`Failed to ${isReady ? 'disconnect' : 'connect'} instance`, {
|
||||
toast.error(
|
||||
isReady
|
||||
? t('settings.remoteInstances.sidebar.toast.disconnectFailed')
|
||||
: t('settings.remoteInstances.sidebar.toast.connectFailed'),
|
||||
{
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Retry',
|
||||
label: t('settings.remoteInstances.sidebar.actions.retry'),
|
||||
icon: RiRefreshLine,
|
||||
onClick: () => {
|
||||
if (!canRetry) return;
|
||||
void retry(instance.id).catch((error) => {
|
||||
toast.error('Failed to retry connection', {
|
||||
toast.error(t('settings.remoteInstances.sidebar.toast.retryFailed'), {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Remove',
|
||||
label: t('settings.remoteInstances.sidebar.actions.remove'),
|
||||
icon: RiDeleteBinLine,
|
||||
destructive: true,
|
||||
onClick: () => {
|
||||
@@ -203,7 +210,7 @@ export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({
|
||||
setSelectedId(next?.id || null);
|
||||
}
|
||||
}).catch((error) => {
|
||||
toast.error('Failed to remove instance', {
|
||||
toast.error(t('settings.remoteInstances.sidebar.toast.removeFailed'), {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,12 +10,14 @@ import { RiArrowDownSLine, RiFolderLine } from '@remixicon/react';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const formatProjectLabel = (label: string): string => {
|
||||
return label.replace(/[-_]/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
};
|
||||
|
||||
export const SettingsProjectSelector: React.FC<{ className?: string }> = ({ className }) => {
|
||||
const { t } = useI18n();
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const setActiveProject = useProjectsStore((state) => state.setActiveProject);
|
||||
@@ -39,7 +41,7 @@ export const SettingsProjectSelector: React.FC<{ className?: string }> = ({ clas
|
||||
|
||||
const rawLabel = activeProject?.label && activeProject.label.trim().length > 0
|
||||
? activeProject.label
|
||||
: (activeProject?.path.split('/').filter(Boolean).pop() || activeProject?.path || 'Project');
|
||||
: (activeProject?.path.split('/').filter(Boolean).pop() || activeProject?.path || t('settings.shared.projectSelector.fallbackProject'));
|
||||
const label = formatProjectLabel(rawLabel);
|
||||
|
||||
return (
|
||||
@@ -48,8 +50,8 @@ export const SettingsProjectSelector: React.FC<{ className?: string }> = ({ clas
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Switch project"
|
||||
title="Switch project"
|
||||
aria-label={t('settings.shared.projectSelector.switchProjectAria')}
|
||||
title={t('settings.shared.projectSelector.switchProjectTitle')}
|
||||
className={cn(
|
||||
// Mirror Input sizing so headers align visually.
|
||||
'text-foreground border border-border/80 appearance-none flex h-8 w-full min-w-0 rounded-lg bg-transparent px-3 py-1 outline-none',
|
||||
|
||||
@@ -23,11 +23,11 @@ import {
|
||||
import { SkillsCatalogPage } from './catalog/SkillsCatalogPage';
|
||||
import {
|
||||
SKILL_LOCATION_OPTIONS,
|
||||
locationLabel,
|
||||
locationPartsFrom,
|
||||
locationValueFrom,
|
||||
type SkillLocationValue,
|
||||
} from './skillLocations';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export interface SkillsPageProps {
|
||||
view?: 'installed' | 'catalog';
|
||||
@@ -38,6 +38,7 @@ const SkillsCatalogStandalone: React.FC = () => (
|
||||
);
|
||||
|
||||
const SkillsInstalledPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
selectedSkillName,
|
||||
getSkillByName,
|
||||
@@ -92,6 +93,32 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
? newFileContent !== originalFileContent
|
||||
: newFileName.trim() !== '';
|
||||
|
||||
const locationLabelText = React.useCallback((value: SkillLocationValue) => {
|
||||
switch (value) {
|
||||
case 'project-opencode':
|
||||
return t('settings.skills.location.option.projectOpencode.label');
|
||||
case 'user-agents':
|
||||
return t('settings.skills.location.option.userAgents.label');
|
||||
case 'project-agents':
|
||||
return t('settings.skills.location.option.projectAgents.label');
|
||||
default:
|
||||
return t('settings.skills.location.option.userOpencode.label');
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const locationDescriptionText = React.useCallback((value: SkillLocationValue) => {
|
||||
switch (value) {
|
||||
case 'project-opencode':
|
||||
return t('settings.skills.location.option.projectOpencode.description');
|
||||
case 'user-agents':
|
||||
return t('settings.skills.location.option.userAgents.description');
|
||||
case 'project-agents':
|
||||
return t('settings.skills.location.option.projectAgents.description');
|
||||
default:
|
||||
return t('settings.skills.location.option.userOpencode.description');
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const loadSkillDetails = async () => {
|
||||
if (isNewSkill && skillDraft) {
|
||||
@@ -131,22 +158,22 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
const skillName = isNewSkill ? draftName.trim().replace(/\s+/g, '-').toLowerCase() : selectedSkillName?.trim();
|
||||
|
||||
if (!skillName) {
|
||||
toast.error('Skill name is required');
|
||||
toast.error(t('settings.skills.page.toast.skillNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(skillName) || skillName.length > 64) {
|
||||
toast.error('Skill name must be 1-64 lowercase alphanumeric characters with hyphens, cannot start or end with hyphen');
|
||||
toast.error(t('settings.skills.page.toast.invalidSkillName'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!description.trim()) {
|
||||
toast.error('Description is required');
|
||||
toast.error(t('settings.skills.page.toast.descriptionRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNewSkill && skills.some((s) => s.name === skillName)) {
|
||||
toast.error('A skill with this name already exists');
|
||||
toast.error(t('settings.skills.page.toast.skillExists'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -179,13 +206,13 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
}
|
||||
|
||||
if (success) {
|
||||
toast.success(isNewSkill ? 'Skill created successfully' : 'Skill updated successfully');
|
||||
toast.success(isNewSkill ? t('settings.skills.page.toast.skillCreated') : t('settings.skills.page.toast.skillUpdated'));
|
||||
} else {
|
||||
toast.error(isNewSkill ? 'Failed to create skill' : 'Failed to update skill');
|
||||
toast.error(isNewSkill ? t('settings.skills.page.toast.createSkillFailed') : t('settings.skills.page.toast.updateSkillFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving skill:', error);
|
||||
toast.error('An error occurred while saving');
|
||||
toast.error(t('settings.skills.page.toast.saveUnexpectedError'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -223,7 +250,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
setNewFileContent(content || '');
|
||||
setOriginalFileContent(content || '');
|
||||
} catch {
|
||||
toast.error('Failed to load file content');
|
||||
toast.error(t('settings.skills.page.toast.loadFileContentFailed'));
|
||||
setNewFileContent('');
|
||||
setOriginalFileContent('');
|
||||
} finally {
|
||||
@@ -233,7 +260,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
|
||||
const handleSaveFile = async () => {
|
||||
if (!newFileName.trim()) {
|
||||
toast.error('File name is required');
|
||||
toast.error(t('settings.skills.page.toast.fileNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -245,14 +272,14 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
setPendingFiles(prev => prev.map(f =>
|
||||
f.path === editingFilePath ? { path: filePath, content: newFileContent } : f
|
||||
));
|
||||
toast.success(`File "${filePath}" updated`);
|
||||
toast.success(t('settings.skills.page.toast.fileUpdated', { path: filePath }));
|
||||
} else {
|
||||
if (pendingFiles.some(f => f.path === filePath)) {
|
||||
toast.error('A file with this name already exists');
|
||||
toast.error(t('settings.skills.page.toast.fileExists'));
|
||||
return;
|
||||
}
|
||||
setPendingFiles(prev => [...prev, { path: filePath, content: newFileContent }]);
|
||||
toast.success(`File "${filePath}" added`);
|
||||
toast.success(t('settings.skills.page.toast.fileAdded', { path: filePath }));
|
||||
}
|
||||
setIsFileDialogOpen(false);
|
||||
setEditingFilePath(null);
|
||||
@@ -260,7 +287,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
}
|
||||
|
||||
if (!selectedSkillName) {
|
||||
toast.error('No skill selected');
|
||||
toast.error(t('settings.skills.page.toast.noSkillSelected'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -268,7 +295,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
const success = await writeSupportingFile(selectedSkillName, filePath, newFileContent);
|
||||
|
||||
if (success) {
|
||||
toast.success(isEditing ? `File "${filePath}" updated` : `File "${filePath}" created`);
|
||||
toast.success(isEditing ? t('settings.skills.page.toast.fileUpdated', { path: filePath }) : t('settings.skills.page.toast.fileCreated', { path: filePath }));
|
||||
setIsFileDialogOpen(false);
|
||||
setEditingFilePath(null);
|
||||
const detail = await getSkillDetail(selectedSkillName);
|
||||
@@ -276,14 +303,14 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
setSupportingFiles(detail.sources.md.supportingFiles || []);
|
||||
}
|
||||
} else {
|
||||
toast.error(isEditing ? 'Failed to update file' : 'Failed to create file');
|
||||
toast.error(isEditing ? t('settings.skills.page.toast.updateFileFailed') : t('settings.skills.page.toast.createFileFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFile = (filePath: string) => {
|
||||
if (isNewSkill) {
|
||||
setPendingFiles(prev => prev.filter(f => f.path !== filePath));
|
||||
toast.success(`File "${filePath}" removed`);
|
||||
toast.success(t('settings.skills.page.toast.fileRemoved', { path: filePath }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -304,14 +331,14 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
const success = await deleteSupportingFile(selectedSkillName, deleteFilePath);
|
||||
|
||||
if (success) {
|
||||
toast.success(`File "${deleteFilePath}" deleted`);
|
||||
toast.success(t('settings.skills.page.toast.fileDeleted', { path: deleteFilePath }));
|
||||
const detail = await getSkillDetail(selectedSkillName);
|
||||
if (detail) {
|
||||
setSupportingFiles(detail.sources.md.supportingFiles || []);
|
||||
}
|
||||
setDeleteFilePath(null);
|
||||
} else {
|
||||
toast.error('Failed to delete file');
|
||||
toast.error(t('settings.skills.page.toast.deleteFileFailed'));
|
||||
}
|
||||
|
||||
setIsDeletingFile(false);
|
||||
@@ -322,8 +349,8 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<RiBookOpenLine className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
||||
<p className="typography-body">Select a skill from the sidebar</p>
|
||||
<p className="typography-meta mt-1 opacity-75">or create a new one</p>
|
||||
<p className="typography-body">{t('settings.skills.page.empty.title')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.skills.page.empty.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -333,7 +360,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p className="typography-body">Loading skill details...</p>
|
||||
<p className="typography-body">{t('settings.skills.page.loading.details')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -347,15 +374,19 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<div className="mb-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground truncate flex items-center gap-2">
|
||||
{isNewSkill ? 'New Skill' : selectedSkillName}
|
||||
{isNewSkill ? t('settings.skills.page.title.newSkill') : selectedSkillName}
|
||||
{selectedSkill?.source === 'claude' && (
|
||||
<span className="typography-micro font-normal bg-[var(--surface-muted)] text-muted-foreground px-1.5 py-0.5 rounded">
|
||||
Claude-compatible
|
||||
{t('settings.skills.page.badge.claudeCompatible')}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<p className="typography-meta text-muted-foreground truncate">
|
||||
{selectedSkill ? `${locationLabel(selectedSkill.scope, selectedSkill.source)} skill` : 'Configure a new skill'}
|
||||
{selectedSkill
|
||||
? t('settings.skills.page.subtitle.skillLocation', {
|
||||
location: locationLabelText(locationValueFrom(selectedSkill.scope, selectedSkill.source)),
|
||||
})
|
||||
: t('settings.skills.page.subtitle.newSkill')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -364,7 +395,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Basic Information
|
||||
{t('settings.skills.page.section.basicInformation')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -372,13 +403,13 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
|
||||
{isNewSkill && (
|
||||
<div className="py-1.5">
|
||||
<span className="typography-ui-label text-foreground">Skill Name & Location</span>
|
||||
<span className="typography-meta text-muted-foreground ml-2">Lowercase, numbers, hyphens</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.skills.page.field.skillNameLocation')}</span>
|
||||
<span className="typography-meta text-muted-foreground ml-2">{t('settings.skills.page.field.skillNameHint')}</span>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<Input
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/\s+/g, '-'))}
|
||||
placeholder="skill-name"
|
||||
placeholder={t('settings.skills.page.field.skillNamePlaceholder')}
|
||||
className="h-7 w-40 px-2"
|
||||
/>
|
||||
<Select
|
||||
@@ -396,7 +427,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<RiFolderLine className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{draftSource === 'agents' ? <RiRobot2Line className="h-3.5 w-3.5" /> : null}
|
||||
<span>{locationLabel(draftScope, draftSource)}</span>
|
||||
<span>{locationLabelText(locationValueFrom(draftScope, draftSource))}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{SKILL_LOCATION_OPTIONS.map((option) => (
|
||||
@@ -405,9 +436,9 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<div className="flex items-center gap-2">
|
||||
{option.scope === 'user' ? <RiUser3Line className="h-3.5 w-3.5" /> : <RiFolderLine className="h-3.5 w-3.5" />}
|
||||
{option.source === 'agents' ? <RiRobot2Line className="h-3.5 w-3.5" /> : null}
|
||||
<span>{option.label}</span>
|
||||
<span>{locationLabelText(option.value)}</span>
|
||||
</div>
|
||||
<span className="typography-micro text-muted-foreground ml-6">{option.description}</span>
|
||||
<span className="typography-micro text-muted-foreground ml-6">{locationDescriptionText(option.value)}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -418,13 +449,13 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
<div className="py-1.5">
|
||||
<span className="typography-ui-label text-foreground">Description <span className="text-[var(--status-error)]">*</span></span>
|
||||
<span className="typography-meta text-muted-foreground ml-2">The agent uses this to decide when to load the skill</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.common.field.description')} <span className="text-[var(--status-error)]">*</span></span>
|
||||
<span className="typography-meta text-muted-foreground ml-2">{t('settings.skills.page.field.descriptionHint')}</span>
|
||||
<div className="mt-1.5">
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of what this skill does..."
|
||||
placeholder={t('settings.skills.page.field.descriptionPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full resize-none min-h-[60px] max-h-32 bg-transparent"
|
||||
/>
|
||||
@@ -438,7 +469,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Instructions
|
||||
{t('settings.skills.page.section.instructions')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -446,7 +477,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<Textarea
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
placeholder="Step-by-step instructions, guidelines, or reference content..."
|
||||
placeholder={t('settings.skills.page.field.instructionsPlaceholder')}
|
||||
className="min-h-[220px] max-h-[60vh] font-mono typography-meta"
|
||||
/>
|
||||
</section>
|
||||
@@ -456,10 +487,10 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<div className="mb-2">
|
||||
<div className="mb-1 px-1 flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Supporting Files
|
||||
{t('settings.skills.page.section.supportingFiles')}
|
||||
</h3>
|
||||
<Button variant="outline" size="xs" className="!font-normal gap-1" onClick={handleAddFile}>
|
||||
<RiAddLine className="h-3.5 w-3.5" /> Add File
|
||||
<RiAddLine className="h-3.5 w-3.5" /> {t('settings.skills.page.actions.addFile')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -470,7 +501,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
if (filesToShow.length === 0) {
|
||||
return (
|
||||
<p className="typography-meta text-muted-foreground py-1.5">
|
||||
No supporting files. Use "Add File" to include reference materials.
|
||||
{t('settings.skills.page.supportingFiles.empty')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -487,7 +518,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<span className="typography-ui-label text-foreground truncate">{file.path}</span>
|
||||
{isNewSkill && (
|
||||
<span className="typography-micro text-[var(--status-warning)] bg-[var(--status-warning)]/10 px-1.5 py-0.5 rounded flex-shrink-0">
|
||||
pending
|
||||
{t('settings.skills.page.badge.pending')}
|
||||
</span>
|
||||
)}
|
||||
<Button size="sm"
|
||||
@@ -516,7 +547,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
>
|
||||
{isSaving ? 'Saving...' : isNewSkill ? 'Create Skill' : 'Save Changes'}
|
||||
{isSaving ? t('settings.common.actions.saving') : isNewSkill ? t('settings.skills.page.actions.createSkill') : t('settings.common.actions.saveChanges')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -533,9 +564,9 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Supporting File</DialogTitle>
|
||||
<DialogTitle>{t('settings.skills.page.deleteFileDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{deleteFilePath}"?
|
||||
{t('settings.skills.page.deleteFileDialog.description', { path: deleteFilePath ?? '' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -545,10 +576,10 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
onClick={() => setDeleteFilePath(null)}
|
||||
disabled={isDeletingFile}
|
||||
>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={handleConfirmDeleteFile} disabled={isDeletingFile}>
|
||||
Delete
|
||||
{t('settings.common.actions.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -560,42 +591,42 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
}}>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>{editingFilePath ? 'Edit Supporting File' : 'Add Supporting File'}</DialogTitle>
|
||||
<DialogTitle>{editingFilePath ? t('settings.skills.page.fileDialog.titleEdit') : t('settings.skills.page.fileDialog.titleAdd')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingFilePath ? 'Modify the file content' : 'Create a new file in the skill directory'}
|
||||
{editingFilePath ? t('settings.skills.page.fileDialog.descriptionEdit') : t('settings.skills.page.fileDialog.descriptionAdd')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isLoadingFile ? (
|
||||
<div className="flex-1 flex items-center justify-center py-8">
|
||||
<span className="typography-meta text-muted-foreground">Loading file content...</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.skills.page.loading.fileContent')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 flex-1 min-h-0 flex flex-col pt-2">
|
||||
<div className="space-y-2 flex-shrink-0">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
File Path
|
||||
{t('settings.skills.page.fileDialog.field.filePath')}
|
||||
</label>
|
||||
<Input
|
||||
value={newFileName}
|
||||
onChange={(e) => setNewFileName(e.target.value)}
|
||||
placeholder="example.md or docs/reference.txt"
|
||||
placeholder={t('settings.skills.page.fileDialog.field.filePathPlaceholder')}
|
||||
className="text-foreground placeholder:text-muted-foreground focus-visible:ring-[var(--primary-base)]"
|
||||
disabled={editingFilePath !== null}
|
||||
/>
|
||||
{!editingFilePath && (
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Relative path within the skill directory. Subdirectories will be created automatically.
|
||||
{t('settings.skills.page.fileDialog.field.filePathHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2 flex-1 min-h-0 flex flex-col">
|
||||
<label className="typography-ui-label font-medium text-foreground flex-shrink-0">
|
||||
Content
|
||||
{t('settings.skills.page.fileDialog.field.content')}
|
||||
</label>
|
||||
<Textarea
|
||||
value={newFileContent}
|
||||
onChange={(e) => setNewFileContent(e.target.value)}
|
||||
placeholder="File content..."
|
||||
placeholder={t('settings.skills.page.fileDialog.field.contentPlaceholder')}
|
||||
outerClassName="h-[45vh] min-h-[250px] max-h-[55vh]"
|
||||
className="h-full min-h-0 font-mono typography-meta"
|
||||
/>
|
||||
@@ -611,10 +642,10 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
setEditingFilePath(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSaveFile} disabled={isLoadingFile || !hasFileChanges}>
|
||||
{editingFilePath ? 'Save Changes' : 'Create File'}
|
||||
{editingFilePath ? t('settings.common.actions.saveChanges') : t('settings.skills.page.actions.createFile')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -23,12 +23,14 @@ import { cn } from '@/lib/utils';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
|
||||
import { SidebarGroup } from '@/components/sections/shared/SidebarGroup';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface SkillsSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
}
|
||||
|
||||
export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const [renameDialogSkill, setRenameDialogSkill] = React.useState<DiscoveredSkill | null>(null);
|
||||
const [renameNewName, setRenameNewName] = React.useState('');
|
||||
const [deleteDialogSkill, setDeleteDialogSkill] = React.useState<DiscoveredSkill | null>(null);
|
||||
@@ -79,10 +81,10 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
setIsDeletePending(true);
|
||||
const success = await deleteSkill(deleteDialogSkill.name);
|
||||
if (success) {
|
||||
toast.success(`Skill "${deleteDialogSkill.name}" deleted successfully`);
|
||||
toast.success(t('settings.skills.sidebar.toast.skillDeleted', { name: deleteDialogSkill.name }));
|
||||
setDeleteDialogSkill(null);
|
||||
} else {
|
||||
toast.error('Failed to delete skill');
|
||||
toast.error(t('settings.skills.sidebar.toast.deleteSkillFailed'));
|
||||
}
|
||||
setIsDeletePending(false);
|
||||
};
|
||||
@@ -100,7 +102,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
// Get full skill detail to copy
|
||||
const detail = await getSkillDetail(skill.name);
|
||||
if (!detail) {
|
||||
toast.error('Failed to load skill details for duplication');
|
||||
toast.error(t('settings.skills.sidebar.toast.duplicateLoadFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -128,7 +130,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
const sanitizedName = renameNewName.trim().replace(/\s+/g, '-').toLowerCase();
|
||||
|
||||
if (!sanitizedName) {
|
||||
toast.error('Skill name is required');
|
||||
toast.error(t('settings.skills.page.toast.skillNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -138,14 +140,14 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
}
|
||||
|
||||
if (skills.some((s) => s.name === sanitizedName)) {
|
||||
toast.error('A skill with this name already exists');
|
||||
toast.error(t('settings.skills.page.toast.skillExists'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get full detail to copy
|
||||
const detail = await getSkillDetail(renameDialogSkill.name);
|
||||
if (!detail) {
|
||||
toast.error('Failed to load skill details');
|
||||
toast.error(t('settings.skills.sidebar.toast.renameLoadFailed'));
|
||||
setRenameDialogSkill(null);
|
||||
return;
|
||||
}
|
||||
@@ -165,10 +167,10 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
toast.success(`Skill renamed to "${sanitizedName}"`);
|
||||
setSelectedSkill(sanitizedName);
|
||||
} else {
|
||||
toast.error('Failed to remove old skill after rename');
|
||||
toast.error(t('settings.skills.sidebar.toast.removeOldAfterRenameFailed'));
|
||||
}
|
||||
} else {
|
||||
toast.error('Failed to rename skill');
|
||||
toast.error(t('settings.skills.sidebar.toast.renameFailed'));
|
||||
}
|
||||
|
||||
setRenameDialogSkill(null);
|
||||
@@ -206,10 +208,10 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">Skills</h2>
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.skills.sidebar.title')}</h2>
|
||||
<SettingsProjectSelector className="mb-3" />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">Total {skills.length}</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.skills.sidebar.total', { count: skills.length })}</span>
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
|
||||
@@ -224,15 +226,15 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
{skills.length === 0 ? (
|
||||
<div className="py-12 px-4 text-center text-muted-foreground">
|
||||
<RiBookOpenLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
||||
<p className="typography-ui-label font-medium">No skills configured</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Use the + button above to create one</p>
|
||||
<p className="typography-ui-label font-medium">{t('settings.skills.sidebar.empty.title')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.skills.sidebar.empty.description')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{projectSkills.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Project Skills
|
||||
{t('settings.skills.sidebar.section.project')}
|
||||
</div>
|
||||
{groupedProjectSkills.sortedGroups.map(({ name: groupName, skills: groupSkills }) => (
|
||||
<SidebarGroup
|
||||
@@ -283,7 +285,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
{userSkills.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pb-1.5 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
User Skills
|
||||
{t('settings.skills.sidebar.section.user')}
|
||||
</div>
|
||||
{groupedUserSkills.sortedGroups.map(({ name: groupName, skills: groupSkills }) => (
|
||||
<SidebarGroup
|
||||
@@ -344,9 +346,9 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Skill</DialogTitle>
|
||||
<DialogTitle>{t('settings.skills.sidebar.deleteDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete skill "{deleteDialogSkill?.name}"?
|
||||
{t('settings.skills.sidebar.deleteDialog.description', { name: deleteDialogSkill?.name ?? '' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -357,10 +359,10 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
onClick={() => setDeleteDialogSkill(null)}
|
||||
disabled={isDeletePending}
|
||||
>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleConfirmDeleteSkill} disabled={isDeletePending}>
|
||||
Delete
|
||||
{t('settings.common.actions.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -370,15 +372,15 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
<Dialog open={renameDialogSkill !== null} onOpenChange={(open) => !open && setRenameDialogSkill(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename Skill</DialogTitle>
|
||||
<DialogTitle>{t('settings.skills.sidebar.renameDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter a new name for the skill "{renameDialogSkill?.name}"
|
||||
{t('settings.skills.sidebar.renameDialog.description', { name: renameDialogSkill?.name ?? '' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={renameNewName}
|
||||
onChange={(e) => setRenameNewName(e.target.value)}
|
||||
placeholder="New skill name..."
|
||||
placeholder={t('settings.skills.sidebar.renameDialog.placeholder')}
|
||||
className="text-foreground placeholder:text-muted-foreground"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
@@ -393,10 +395,10 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
variant="ghost"
|
||||
onClick={() => setRenameDialogSkill(null)}
|
||||
>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleRenameSkill}>
|
||||
Rename
|
||||
{t('settings.common.actions.rename')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -426,6 +428,7 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
|
||||
isMenuOpen,
|
||||
onMenuOpenChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isMobile = isMobileDeviceViaCSS();
|
||||
return (
|
||||
<div
|
||||
@@ -453,12 +456,12 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
|
||||
</span>
|
||||
{skill.source === 'claude' && (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
claude
|
||||
{t('settings.skills.sidebar.badge.claude')}
|
||||
</span>
|
||||
)}
|
||||
{skill.source === 'agents' && (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
agents
|
||||
{t('settings.skills.sidebar.badge.agents')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -481,7 +484,7 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
|
||||
}}
|
||||
>
|
||||
<RiEditLine className="h-4 w-4 mr-px" />
|
||||
Rename
|
||||
{t('settings.common.actions.rename')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
@@ -491,7 +494,7 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
|
||||
}}
|
||||
>
|
||||
<RiFileCopyLine className="h-4 w-4 mr-px" />
|
||||
Duplicate
|
||||
{t('settings.common.actions.duplicate')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
@@ -502,7 +505,7 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-px" />
|
||||
Delete
|
||||
{t('settings.common.actions.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
|
||||
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const generateCatalogId = () => `custom:${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
@@ -77,6 +78,7 @@ interface AddCatalogDialogProps {
|
||||
}
|
||||
|
||||
export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpenChange }) => {
|
||||
const { t } = useI18n();
|
||||
const { scanRepo, loadCatalog, isScanning } = useSkillsCatalogStore();
|
||||
const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId);
|
||||
const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId);
|
||||
@@ -126,7 +128,7 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
const handleScan = async () => {
|
||||
const trimmedSource = source.trim();
|
||||
if (!trimmedSource) {
|
||||
toast.error('Repository source is required');
|
||||
toast.error(t('settings.skills.catalog.add.toast.repositoryRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -146,7 +148,7 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
if (!result.ok) {
|
||||
if (result.error?.kind === 'authRequired') {
|
||||
if (isVSCodeRuntime()) {
|
||||
toast.error('Private repositories are not supported in VS Code yet');
|
||||
toast.error(t('settings.skills.catalog.shared.toast.privateRepoNotSupportedVsCode'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -161,25 +163,25 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
: ids[0].id;
|
||||
setGitIdentityId(preferred);
|
||||
}
|
||||
toast.error('Authentication required. Select a Git identity and scan again.');
|
||||
toast.error(t('settings.skills.catalog.add.toast.authenticationRequiredScan'));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error?.message || 'Failed to scan repository');
|
||||
toast.error(result.error?.message || t('settings.skills.catalog.add.toast.scanFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
const count = result.items?.length || 0;
|
||||
setScanCount(count);
|
||||
if (count === 0) {
|
||||
toast.error('No skills found in this repository');
|
||||
toast.error(t('settings.skills.catalog.add.toast.noSkillsFound'));
|
||||
setScanOk(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIdentityOptions([]);
|
||||
setScanOk(true);
|
||||
toast.success(`Found ${count} skill(s)`);
|
||||
toast.success(t('settings.skills.catalog.shared.toast.foundSkills', { count }));
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
@@ -188,22 +190,22 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
const trimmedSubpath = subpath.trim();
|
||||
|
||||
if (!trimmedLabel) {
|
||||
toast.error('Catalog name is required');
|
||||
toast.error(t('settings.skills.catalog.add.toast.catalogNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trimmedSource) {
|
||||
toast.error('Repository source is required');
|
||||
toast.error(t('settings.skills.catalog.add.toast.repositoryRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scanOk) {
|
||||
toast.error('Scan the repository before adding this catalog');
|
||||
toast.error(t('settings.skills.catalog.add.toast.scanBeforeAdd'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDuplicate) {
|
||||
toast.error('This catalog already exists');
|
||||
toast.error(t('settings.skills.catalog.add.toast.catalogAlreadyExists'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -220,11 +222,11 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
try {
|
||||
await updateDesktopSettings({ skillCatalogs: updated });
|
||||
setExistingCatalogs(updated);
|
||||
toast.success('Catalog added');
|
||||
toast.success(t('settings.skills.catalog.add.toast.catalogAdded'));
|
||||
await loadCatalog({ refresh: true });
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to save catalog');
|
||||
toast.error(error instanceof Error ? error.message : t('settings.skills.catalog.add.toast.saveFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -232,20 +234,23 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add skills catalog</DialogTitle>
|
||||
<DialogTitle>{t('settings.skills.catalog.add.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a Git repository as a new catalog source. OpenChamber will scan it for folders containing <code className="font-mono">SKILL.md</code>.
|
||||
{t('settings.skills.catalog.add.descriptionPrefix')}
|
||||
{' '}
|
||||
<code className="font-mono">SKILL.md</code>
|
||||
{t('settings.skills.catalog.add.descriptionSuffix')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label text-foreground">Catalog name</label>
|
||||
<Input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="e.g. Team Skills" />
|
||||
<label className="typography-ui-label text-foreground">{t('settings.skills.catalog.add.field.catalogName')}</label>
|
||||
<Input value={label} onChange={(e) => setLabel(e.target.value)} placeholder={t('settings.skills.catalog.add.field.catalogNamePlaceholder')} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label text-foreground">Repository</label>
|
||||
<label className="typography-ui-label text-foreground">{t('settings.skills.catalog.add.field.repository')}</label>
|
||||
<Input
|
||||
value={source}
|
||||
onChange={(e) => {
|
||||
@@ -253,15 +258,15 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
setScanOk(false);
|
||||
setScanCount(null);
|
||||
}}
|
||||
placeholder="owner/repo or git@github.com:owner/repo.git"
|
||||
placeholder={t('settings.skills.catalog.shared.field.repositoryPlaceholder')}
|
||||
/>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Public repos work everywhere. Private repos require SSH identity (Desktop/Web only).
|
||||
{t('settings.skills.catalog.add.field.repositoryHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label text-foreground">Optional subpath</label>
|
||||
<label className="typography-ui-label text-foreground">{t('settings.skills.catalog.add.field.optionalSubpath')}</label>
|
||||
<Input
|
||||
value={subpath}
|
||||
onChange={(e) => {
|
||||
@@ -269,19 +274,19 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
setScanOk(false);
|
||||
setScanCount(null);
|
||||
}}
|
||||
placeholder="e.g. skills"
|
||||
placeholder={t('settings.skills.catalog.shared.field.subpathPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{identityOptions.length > 0 && !isVSCodeRuntime() ? (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<span className="typography-ui-label text-[var(--status-warning)]">Authentication required</span>
|
||||
<span className="typography-meta text-muted-foreground ml-2">Select a Git identity (SSH key)</span>
|
||||
<span className="typography-ui-label text-[var(--status-warning)]">{t('settings.skills.catalog.shared.auth.title')}</span>
|
||||
<span className="typography-meta text-muted-foreground ml-2">{t('settings.skills.catalog.shared.auth.description')}</span>
|
||||
</div>
|
||||
<Select value={gitIdentityId || ''} onValueChange={(v) => setGitIdentityId(v)}>
|
||||
<SelectTrigger className="w-fit">
|
||||
<span>{identityOptions.find((i) => i.id === gitIdentityId)?.name || 'Choose identity'}</span>
|
||||
<span>{identityOptions.find((i) => i.id === gitIdentityId)?.name || t('settings.skills.catalog.shared.auth.chooseIdentity')}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{identityOptions.map((id) => (
|
||||
@@ -292,27 +297,27 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Configure identities in Settings - Git Identities.
|
||||
{t('settings.skills.catalog.shared.auth.footerHint')}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{scanCount !== null ? (
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
Scan result: {scanCount} skill(s) found
|
||||
{t('settings.skills.catalog.add.scanResult', { count: scanCount })}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isDuplicate ? (
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
This catalog is already added.
|
||||
{t('settings.skills.catalog.add.duplicateMessage')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button size="sm" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -322,14 +327,14 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
disabled={isScanning || !source.trim()}
|
||||
>
|
||||
<RiGitRepositoryLine className="h-4 w-4" />
|
||||
{isScanning ? 'Scanning...' : 'Scan'}
|
||||
{isScanning ? t('settings.skills.catalog.shared.actions.scanning') : t('settings.skills.catalog.shared.actions.scan')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleAdd()}
|
||||
disabled={!scanOk || isDuplicate || !label.trim() || !source.trim()}
|
||||
>
|
||||
Add catalog
|
||||
{t('settings.skills.catalog.add.actions.addCatalog')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@/components/ui/select';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export type SkillConflict = {
|
||||
skillName: string;
|
||||
@@ -37,6 +38,7 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
|
||||
conflicts,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [decisions, setDecisions] = React.useState<Record<string, ConflictDecision>>({});
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -62,18 +64,18 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Skills already exist</DialogTitle>
|
||||
<DialogTitle>{t('settings.skills.catalog.conflicts.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Some selected skills are already installed in this scope. Choose whether to skip or overwrite them.
|
||||
{t('settings.skills.catalog.conflicts.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">{conflicts.length} conflict(s)</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.skills.catalog.conflicts.count', { count: conflicts.length })}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => setAll('skip')}>Skip all</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => setAll('overwrite')}>Overwrite all</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => setAll('skip')}>{t('settings.skills.catalog.conflicts.actions.skipAll')}</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => setAll('overwrite')}>{t('settings.skills.catalog.conflicts.actions.overwriteAll')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -86,7 +88,14 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
|
||||
<div className="min-w-0">
|
||||
<div className="typography-ui-label truncate">{conflict.skillName}</div>
|
||||
<div className="typography-micro text-muted-foreground">
|
||||
Installed in {conflict.scope} / {conflict.source || 'opencode'}
|
||||
{t('settings.skills.catalog.conflicts.installedIn', {
|
||||
scope: conflict.scope === 'project'
|
||||
? t('settings.skills.catalog.conflicts.scope.project')
|
||||
: t('settings.skills.catalog.conflicts.scope.user'),
|
||||
source: conflict.source === 'agents'
|
||||
? t('settings.skills.catalog.conflicts.source.agents')
|
||||
: t('settings.skills.catalog.conflicts.source.opencode'),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,14 +104,18 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
|
||||
onValueChange={(v) => setDecisions((prev) => ({ ...prev, [conflict.skillName]: v as ConflictDecision }))}
|
||||
>
|
||||
<SelectTrigger className="w-fit">
|
||||
<span className="capitalize">{decisions[conflict.skillName] || 'skip'}</span>
|
||||
<span className="capitalize">
|
||||
{(decisions[conflict.skillName] || 'skip') === 'overwrite'
|
||||
? t('settings.skills.catalog.conflicts.decision.overwrite')
|
||||
: t('settings.skills.catalog.conflicts.decision.skip')}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="skip" className="pr-2 [&>span:first-child]:hidden">
|
||||
Skip
|
||||
{t('settings.skills.catalog.conflicts.decision.skip')}
|
||||
</SelectItem>
|
||||
<SelectItem value="overwrite" className="pr-2 [&>span:first-child]:hidden">
|
||||
Overwrite
|
||||
{t('settings.skills.catalog.conflicts.decision.overwrite')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -113,14 +126,14 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
|
||||
|
||||
<DialogFooter>
|
||||
<Button size="sm" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onConfirm(decisions)}
|
||||
disabled={!canConfirm}
|
||||
>
|
||||
Continue
|
||||
{t('settings.skills.catalog.conflicts.actions.continue')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -29,9 +29,9 @@ import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
SKILL_LOCATION_OPTIONS,
|
||||
locationLabel,
|
||||
locationPartsFrom,
|
||||
locationValueFrom,
|
||||
type SkillLocationValue,
|
||||
@@ -45,6 +45,7 @@ interface InstallFromRepoDialogProps {
|
||||
type IdentityOption = { id: string; name: string };
|
||||
|
||||
export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ open, onOpenChange }) => {
|
||||
const { t } = useI18n();
|
||||
const { scanRepo, installSkills, isScanning, isInstalling } = useSkillsCatalogStore();
|
||||
const installedSkills = useSkillsStore((s) => s.skills);
|
||||
const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId);
|
||||
@@ -153,10 +154,36 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
setSelected(next);
|
||||
};
|
||||
|
||||
const locationLabelText = React.useCallback((value: SkillLocationValue) => {
|
||||
switch (value) {
|
||||
case 'project-opencode':
|
||||
return t('settings.skills.location.option.projectOpencode.label');
|
||||
case 'user-agents':
|
||||
return t('settings.skills.location.option.userAgents.label');
|
||||
case 'project-agents':
|
||||
return t('settings.skills.location.option.projectAgents.label');
|
||||
default:
|
||||
return t('settings.skills.location.option.userOpencode.label');
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const locationDescriptionText = React.useCallback((value: SkillLocationValue) => {
|
||||
switch (value) {
|
||||
case 'project-opencode':
|
||||
return t('settings.skills.location.option.projectOpencode.description');
|
||||
case 'user-agents':
|
||||
return t('settings.skills.location.option.userAgents.description');
|
||||
case 'project-agents':
|
||||
return t('settings.skills.location.option.projectAgents.description');
|
||||
default:
|
||||
return t('settings.skills.location.option.userOpencode.description');
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleScan = async () => {
|
||||
const trimmed = source.trim();
|
||||
if (!trimmed) {
|
||||
toast.error('Repository source is required');
|
||||
toast.error(t('settings.skills.catalog.shared.toast.repositoryRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -169,7 +196,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
if (!result.ok) {
|
||||
if (result.error?.kind === 'authRequired') {
|
||||
if (isVSCodeRuntime()) {
|
||||
toast.error('Private repositories are not supported in VS Code yet');
|
||||
toast.error(t('settings.skills.catalog.shared.toast.privateRepoNotSupportedVsCode'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -184,11 +211,11 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
: ids[0].id;
|
||||
setGitIdentityId(preferred);
|
||||
}
|
||||
toast.error('Authentication required. Select a Git identity and try scanning again.');
|
||||
toast.error(t('settings.skills.catalog.installFromRepo.toast.authenticationRequiredScan'));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error?.message || 'Failed to scan repository');
|
||||
toast.error(result.error?.message || t('settings.skills.catalog.installFromRepo.toast.scanFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -205,12 +232,12 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
setSelected(nextSelected);
|
||||
|
||||
setIdentities([]);
|
||||
toast.success(`Found ${nextItems.length} skill(s)`);
|
||||
toast.success(t('settings.skills.catalog.shared.toast.foundSkills', { count: nextItems.length }));
|
||||
};
|
||||
|
||||
const doInstall = async (opts: { conflictDecisions?: Record<string, ConflictDecision> }) => {
|
||||
if (selectedDirs.length === 0) {
|
||||
toast.error('Select at least one skill to install');
|
||||
toast.error(t('settings.skills.catalog.installFromRepo.toast.selectAtLeastOne'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -240,7 +267,11 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
|
||||
if (result.ok) {
|
||||
const installedCount = result.installed?.length || 0;
|
||||
toast.success(installedCount > 0 ? `Installed ${installedCount} skill(s)` : 'Installation completed');
|
||||
toast.success(
|
||||
installedCount > 0
|
||||
? t('settings.skills.catalog.installFromRepo.toast.installedCount', { count: installedCount })
|
||||
: t('settings.skills.catalog.installFromRepo.toast.installCompleted')
|
||||
);
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
@@ -254,7 +285,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
|
||||
if (result.error?.kind === 'authRequired') {
|
||||
if (isVSCodeRuntime()) {
|
||||
toast.error('Private repositories are not supported in VS Code yet');
|
||||
toast.error(t('settings.skills.catalog.shared.toast.privateRepoNotSupportedVsCode'));
|
||||
return;
|
||||
}
|
||||
const ids = (result.error.identities || []) as IdentityOption[];
|
||||
@@ -268,11 +299,11 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
: ids[0].id;
|
||||
setGitIdentityId(preferred);
|
||||
}
|
||||
toast.error('Authentication required. Select a Git identity and try installing again.');
|
||||
toast.error(t('settings.skills.catalog.installFromRepo.toast.authenticationRequiredInstall'));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error?.message || 'Failed to install skills');
|
||||
toast.error(result.error?.message || t('settings.skills.catalog.installFromRepo.toast.installFailed'));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -280,20 +311,23 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>Install from Git repository</DialogTitle>
|
||||
<DialogTitle>{t('settings.skills.catalog.installFromRepo.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Scan a repository for folders containing <code className="font-mono">SKILL.md</code>, then install selected skills.
|
||||
{t('settings.skills.catalog.installFromRepo.descriptionPrefix')}
|
||||
{' '}
|
||||
<code className="font-mono">SKILL.md</code>
|
||||
{t('settings.skills.catalog.installFromRepo.descriptionSuffix')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 flex-shrink-0">
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">Repository</label>
|
||||
<label className="typography-ui-label font-medium text-foreground">{t('settings.skills.catalog.shared.field.repository')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
placeholder="owner/repo or git@github.com:owner/repo.git"
|
||||
placeholder={t('settings.skills.catalog.shared.field.repositoryPlaceholder')}
|
||||
className="text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
<Button
|
||||
@@ -304,27 +338,30 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
className="gap-2"
|
||||
>
|
||||
<RiGitRepositoryLine className="h-4 w-4" />
|
||||
{isScanning ? 'Scanning…' : 'Scan'}
|
||||
{isScanning ? t('settings.skills.catalog.shared.actions.scanning') : t('settings.skills.catalog.shared.actions.scan')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
For GitHub shorthand, you can add a subpath like <code className="font-mono">owner/repo/skills</code>.
|
||||
{t('settings.skills.catalog.installFromRepo.repositoryHintPrefix')}
|
||||
{' '}
|
||||
<code className="font-mono">owner/repo/skills</code>
|
||||
{'.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">Optional subpath</label>
|
||||
<label className="typography-ui-label font-medium text-foreground">{t('settings.skills.catalog.shared.field.optionalSubpath')}</label>
|
||||
<Input
|
||||
value={subpath}
|
||||
onChange={(e) => setSubpath(e.target.value)}
|
||||
placeholder="e.g. skills"
|
||||
placeholder={t('settings.skills.catalog.shared.field.subpathPlaceholder')}
|
||||
className="text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">Target location</label>
|
||||
<label className="typography-ui-label font-medium text-foreground">{t('settings.skills.catalog.shared.field.targetLocation')}</label>
|
||||
<Select
|
||||
value={locationValueFrom(scope, targetSource)}
|
||||
onValueChange={(v) => {
|
||||
@@ -336,7 +373,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
<SelectTrigger size="lg" className="w-full gap-1.5">
|
||||
{scope === 'user' ? <RiUser3Line className="h-4 w-4" /> : <RiFolderLine className="h-4 w-4" />}
|
||||
{targetSource === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null}
|
||||
<span>{locationLabel(scope, targetSource)}</span>
|
||||
<span>{locationLabelText(locationValueFrom(scope, targetSource))}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{SKILL_LOCATION_OPTIONS.map((option) => (
|
||||
@@ -345,9 +382,9 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
<div className="flex items-center gap-2">
|
||||
{option.scope === 'user' ? <RiUser3Line className="h-4 w-4" /> : <RiFolderLine className="h-4 w-4" />}
|
||||
{option.source === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null}
|
||||
<span>{option.label}</span>
|
||||
<span>{locationLabelText(option.value)}</span>
|
||||
</div>
|
||||
<span className="typography-micro text-muted-foreground ml-6">{option.description}</span>
|
||||
<span className="typography-micro text-muted-foreground ml-6">{locationDescriptionText(option.value)}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -358,9 +395,9 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
|
||||
{scope === 'project' && (
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">Project</label>
|
||||
<label className="typography-ui-label font-medium text-foreground">{t('settings.skills.catalog.shared.field.project')}</label>
|
||||
{projects.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">No projects available</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.skills.catalog.shared.field.noProjects')}</p>
|
||||
) : (
|
||||
<Select
|
||||
value={resolvedTargetProjectId ?? ''}
|
||||
@@ -368,7 +405,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
disabled={projects.length === 1}
|
||||
>
|
||||
<SelectTrigger size="lg" className="w-full justify-between">
|
||||
<SelectValue placeholder="Choose project" />
|
||||
<SelectValue placeholder={t('settings.skills.catalog.shared.field.chooseProjectPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{projects.map((p) => (
|
||||
@@ -384,14 +421,14 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
|
||||
{identities.length > 0 && !isVSCodeRuntime() ? (
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
||||
<div className="typography-ui-label font-medium text-foreground">Authentication required</div>
|
||||
<div className="typography-ui-label font-medium text-foreground">{t('settings.skills.catalog.shared.auth.title')}</div>
|
||||
<div className="typography-meta text-muted-foreground mt-1">
|
||||
Select a Git identity (SSH key) that can access this repository.
|
||||
{t('settings.skills.catalog.installFromRepo.authDescription')}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Select value={gitIdentityId || ''} onValueChange={(v) => setGitIdentityId(v)}>
|
||||
<SelectTrigger size="lg" className="w-full justify-between">
|
||||
<span>{identities.find((i) => i.id === gitIdentityId)?.name || 'Choose identity'}</span>
|
||||
<span>{identities.find((i) => i.id === gitIdentityId)?.name || t('settings.skills.catalog.shared.auth.chooseIdentity')}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{identities.map((id) => (
|
||||
@@ -403,7 +440,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
</Select>
|
||||
</div>
|
||||
<div className="typography-micro text-muted-foreground mt-2">
|
||||
Configure identities in Settings → Git Identities.
|
||||
{t('settings.skills.catalog.shared.auth.footerHintArrow')}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -413,8 +450,8 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
{items.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-center text-muted-foreground">
|
||||
<div>
|
||||
<p className="typography-body">No scan results yet</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Scan a repository to discover skills</p>
|
||||
<p className="typography-body">{t('settings.skills.catalog.installFromRepo.empty.noScanResultsTitle')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.skills.catalog.installFromRepo.empty.noScanResultsDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -423,12 +460,12 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search skills…"
|
||||
placeholder={t('settings.skills.catalog.shared.field.searchSkillsPlaceholder')}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => toggleAll(true)}>Select all</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => toggleAll(false)}>Select none</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => toggleAll(true)}>{t('settings.skills.catalog.installFromRepo.actions.selectAll')}</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => toggleAll(false)}>{t('settings.skills.catalog.installFromRepo.actions.selectNone')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -458,14 +495,17 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
<div className="typography-ui-label truncate">{item.skillName}</div>
|
||||
{installed ? (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
installed ({installed.scope}/{installed.source})
|
||||
{t('settings.skills.catalog.installFromRepo.badge.installed', {
|
||||
scope: installed.scope,
|
||||
source: installed.source,
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{item.description ? (
|
||||
<div className="typography-meta text-muted-foreground mt-0.5 line-clamp-2">{item.description}</div>
|
||||
) : (
|
||||
<div className="typography-micro text-muted-foreground mt-0.5">No description provided</div>
|
||||
<div className="typography-micro text-muted-foreground mt-0.5">{t('settings.skills.catalog.shared.noDescription')}</div>
|
||||
)}
|
||||
{item.warnings?.length ? (
|
||||
<div className="typography-micro text-muted-foreground mt-1">
|
||||
@@ -479,7 +519,10 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
</ScrollableOverlay>
|
||||
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
Selected: {selectedDirs.length} / {items.filter((i) => i.installable).length}
|
||||
{t('settings.skills.catalog.installFromRepo.selectedCount', {
|
||||
selected: selectedDirs.length,
|
||||
total: items.filter((i) => i.installable).length,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -487,14 +530,14 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
|
||||
<DialogFooter className="flex-shrink-0">
|
||||
<Button size="sm" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isInstalling || selectedDirs.length === 0 || !source.trim() || (scope === 'project' && !directoryOverride)}
|
||||
onClick={() => void doInstall({})}
|
||||
>
|
||||
{isInstalling ? 'Installing…' : 'Install selected'}
|
||||
{isInstalling ? t('settings.skills.catalog.shared.actions.installing') : t('settings.skills.catalog.installFromRepo.actions.installSelected')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { RiFolderLine, RiRobot2Line, RiUser3Line } from '@remixicon/react';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
import type { SkillsCatalogItem } from '@/lib/api/types';
|
||||
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||
@@ -25,7 +26,6 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog';
|
||||
import {
|
||||
SKILL_LOCATION_OPTIONS,
|
||||
locationLabel,
|
||||
locationPartsFrom,
|
||||
locationValueFrom,
|
||||
type SkillLocationValue,
|
||||
@@ -38,6 +38,7 @@ interface InstallSkillDialogProps {
|
||||
}
|
||||
|
||||
export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, onOpenChange, item }) => {
|
||||
const { t } = useI18n();
|
||||
const { installSkills, isInstalling } = useSkillsCatalogStore();
|
||||
const [scope, setScope] = React.useState<'user' | 'project'>('user');
|
||||
const [targetSource, setTargetSource] = React.useState<'opencode' | 'agents'>('opencode');
|
||||
@@ -65,6 +66,32 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
setBaseRequest(null);
|
||||
}, [open, activeProjectId]);
|
||||
|
||||
const locationLabelText = React.useCallback((value: SkillLocationValue) => {
|
||||
switch (value) {
|
||||
case 'project-opencode':
|
||||
return t('settings.skills.location.option.projectOpencode.label');
|
||||
case 'user-agents':
|
||||
return t('settings.skills.location.option.userAgents.label');
|
||||
case 'project-agents':
|
||||
return t('settings.skills.location.option.projectAgents.label');
|
||||
default:
|
||||
return t('settings.skills.location.option.userOpencode.label');
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const locationDescriptionText = React.useCallback((value: SkillLocationValue) => {
|
||||
switch (value) {
|
||||
case 'project-opencode':
|
||||
return t('settings.skills.location.option.projectOpencode.description');
|
||||
case 'user-agents':
|
||||
return t('settings.skills.location.option.userAgents.description');
|
||||
case 'project-agents':
|
||||
return t('settings.skills.location.option.projectAgents.description');
|
||||
default:
|
||||
return t('settings.skills.location.option.userOpencode.description');
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const resolvedTargetProjectId = React.useMemo(() => {
|
||||
if (projects.length === 0) {
|
||||
return null;
|
||||
@@ -122,7 +149,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
}, { directory: request.directoryOverride ?? null });
|
||||
|
||||
if (result.ok) {
|
||||
toast.success('Skill installed successfully');
|
||||
toast.success(t('settings.skills.catalog.installSkill.toast.installed'));
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
@@ -142,11 +169,11 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
}
|
||||
|
||||
if (result.error?.kind === 'authRequired') {
|
||||
toast.error(result.error.message || 'Authentication required');
|
||||
toast.error(result.error.message || t('settings.skills.catalog.installSkill.toast.authRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error?.message || 'Failed to install skill');
|
||||
toast.error(result.error?.message || t('settings.skills.catalog.installSkill.toast.installFailed'));
|
||||
};
|
||||
|
||||
if (!item) {
|
||||
@@ -158,15 +185,19 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Install skill</DialogTitle>
|
||||
<DialogTitle>{t('settings.skills.catalog.installSkill.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Install <span className="font-semibold text-foreground">{item.skillName}</span> into one of four target locations.
|
||||
{t('settings.skills.catalog.installSkill.descriptionPrefix')}
|
||||
{' '}
|
||||
<span className="font-semibold text-foreground">{item.skillName}</span>
|
||||
{' '}
|
||||
{t('settings.skills.catalog.installSkill.descriptionSuffix')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-2 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="typography-ui-label text-foreground">Destination</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.skills.catalog.installSkill.field.destination')}</span>
|
||||
<Select
|
||||
value={locationValueFrom(scope, targetSource)}
|
||||
onValueChange={(v) => {
|
||||
@@ -178,7 +209,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
<SelectTrigger className="w-fit gap-1.5">
|
||||
{scope === 'user' ? <RiUser3Line className="h-3.5 w-3.5" /> : <RiFolderLine className="h-3.5 w-3.5" />}
|
||||
{targetSource === 'agents' ? <RiRobot2Line className="h-3.5 w-3.5" /> : null}
|
||||
<span>{locationLabel(scope, targetSource)}</span>
|
||||
<span>{locationLabelText(locationValueFrom(scope, targetSource))}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{SKILL_LOCATION_OPTIONS.map((option) => (
|
||||
@@ -187,9 +218,9 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
<div className="flex items-center gap-2">
|
||||
{option.scope === 'user' ? <RiUser3Line className="h-3.5 w-3.5" /> : <RiFolderLine className="h-3.5 w-3.5" />}
|
||||
{option.source === 'agents' ? <RiRobot2Line className="h-3.5 w-3.5" /> : null}
|
||||
<span>{option.label}</span>
|
||||
<span>{locationLabelText(option.value)}</span>
|
||||
</div>
|
||||
<span className="typography-micro text-muted-foreground ml-5">{option.description}</span>
|
||||
<span className="typography-micro text-muted-foreground ml-5">{locationDescriptionText(option.value)}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -199,9 +230,9 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
|
||||
{scope === 'project' && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="typography-ui-label text-foreground">Project</span>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.skills.catalog.installSkill.field.project')}</span>
|
||||
{projects.length === 0 ? (
|
||||
<span className="typography-meta text-muted-foreground">No projects available</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.skills.catalog.installSkill.field.noProjects')}</span>
|
||||
) : (
|
||||
<Select
|
||||
value={resolvedTargetProjectId ?? ''}
|
||||
@@ -209,7 +240,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
disabled={projects.length === 1}
|
||||
>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectValue placeholder="Choose project" />
|
||||
<SelectValue placeholder={t('settings.skills.catalog.installSkill.field.chooseProjectPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{projects.map((p) => (
|
||||
@@ -236,7 +267,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -252,7 +283,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
})
|
||||
}
|
||||
>
|
||||
{isInstalling ? 'Installing...' : 'Install'}
|
||||
{isInstalling ? t('settings.skills.catalog.installSkill.actions.installing') : t('settings.skills.catalog.installSkill.actions.install')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { SkillsCatalogItem } from '@/lib/api/types';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
import { AddCatalogDialog } from './AddCatalogDialog';
|
||||
import { InstallSkillDialog } from './InstallSkillDialog';
|
||||
@@ -65,6 +66,7 @@ const loadSettings = async (): Promise<DesktopSettings | null> => {
|
||||
};
|
||||
|
||||
export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onModeChange, showModeTabs = true }) => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
sources,
|
||||
itemsBySource,
|
||||
@@ -154,8 +156,8 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
<div className="h-10">
|
||||
<SortableTabsStrip
|
||||
items={[
|
||||
{ id: 'manual', label: 'Manual' },
|
||||
{ id: 'external', label: 'External' },
|
||||
{ id: 'manual', label: t('settings.skills.catalog.page.mode.manual') },
|
||||
{ id: 'external', label: t('settings.skills.catalog.page.mode.external') },
|
||||
]}
|
||||
activeId={mode}
|
||||
onSelect={(next) => onModeChange(next as 'manual' | 'external')}
|
||||
@@ -167,13 +169,13 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<h2 className="typography-ui-header font-semibold text-foreground px-1">Skills Catalog</h2>
|
||||
<h2 className="typography-ui-header font-semibold text-foreground px-1">{t('settings.skills.catalog.page.title')}</h2>
|
||||
</div>
|
||||
|
||||
{/* Source & Search */}
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Source Repository</h3>
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.skills.catalog.page.section.sourceRepository')}</h3>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-0">
|
||||
@@ -183,7 +185,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
onValueChange={(v) => setSelectedSource(v)}
|
||||
>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectValue placeholder="Select source" />
|
||||
<SelectValue placeholder={t('settings.skills.catalog.page.field.selectSourcePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{sources.map((src) => (
|
||||
@@ -206,7 +208,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
}
|
||||
}}
|
||||
disabled={isLoadingCatalog || isLoadingSource}
|
||||
title="Refresh"
|
||||
title={t('settings.skills.catalog.page.actions.refreshTitle')}
|
||||
>
|
||||
<RiRefreshLine className={cn("h-3.5 w-3.5", (isLoadingCatalog || isLoadingSource) && "animate-spin")} />
|
||||
</Button>
|
||||
@@ -218,7 +220,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
className="!font-normal h-6 w-6 px-0 text-[var(--status-error)] hover:text-[var(--status-error)]"
|
||||
onClick={() => setIsRemoveCatalogDialogOpen(true)}
|
||||
disabled={isRemovingCatalog}
|
||||
title="Remove Catalog"
|
||||
title={t('settings.skills.catalog.page.actions.removeCatalogTitle')}
|
||||
>
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -229,7 +231,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
className="!font-normal gap-1"
|
||||
onClick={() => setAddCatalogOpen(true)}
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" /> Add Catalog
|
||||
<RiAddLine className="h-3.5 w-3.5" /> {t('settings.skills.catalog.page.actions.addCatalog')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -239,12 +241,14 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search skills..."
|
||||
placeholder={t('settings.skills.catalog.shared.field.searchSkillsPlaceholder')}
|
||||
className="h-7 pl-8 w-full sm:w-64"
|
||||
/>
|
||||
</div>
|
||||
<span className="typography-meta text-muted-foreground mt-1 block">
|
||||
{isLoadingCatalog ? 'Loading...' : `${filtered.length} skill(s) found`}
|
||||
{isLoadingCatalog
|
||||
? t('settings.skills.catalog.page.loading.catalog')
|
||||
: t('settings.skills.catalog.page.foundCount', { count: filtered.length })}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -253,7 +257,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
{/* Error State */}
|
||||
{lastCatalogError && (
|
||||
<div className="mb-8 rounded-lg border border-[var(--status-error-border)] bg-[var(--status-error-background)] px-4 py-3">
|
||||
<div className="typography-ui-label font-medium text-[var(--status-error)]">Catalog error</div>
|
||||
<div className="typography-ui-label font-medium text-[var(--status-error)]">{t('settings.skills.catalog.page.error.catalogTitle')}</div>
|
||||
<div className="typography-meta text-[var(--status-error)]/80 mt-1">{lastCatalogError.message}</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -263,13 +267,13 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
{filtered.length === 0 && !isLoadingSource ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<p className="typography-body">No skills found</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Try a different search or refresh the catalog</p>
|
||||
<p className="typography-body">{t('settings.skills.catalog.page.empty.noSkillsTitle')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.skills.catalog.page.empty.noSkillsDescription')}</p>
|
||||
</div>
|
||||
) : isLoadingSource ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<RiRefreshLine className="mx-auto mb-3 h-5 w-5 animate-spin opacity-50" />
|
||||
<p className="typography-meta">Loading skills...</p>
|
||||
<p className="typography-meta">{t('settings.skills.catalog.page.loading.skills')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--surface-subtle)]">
|
||||
@@ -285,12 +289,12 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
<span className="typography-ui-label font-medium text-foreground truncate">{item.skillName}</span>
|
||||
{installed && (
|
||||
<span className="typography-micro text-[var(--status-success)] bg-[var(--status-success)]/10 px-1.5 py-0.5 rounded flex-shrink-0">
|
||||
installed ({installedScope || 'unknown'})
|
||||
{t('settings.skills.catalog.page.badge.installed', { scope: installedScope || t('settings.skills.catalog.page.badge.unknown') })}
|
||||
</span>
|
||||
)}
|
||||
{!item.installable && (
|
||||
<span className="typography-micro text-[var(--status-warning)] bg-[var(--status-warning)]/10 px-1.5 py-0.5 rounded flex-shrink-0">
|
||||
not installable
|
||||
{t('settings.skills.catalog.page.badge.notInstallable')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -298,13 +302,13 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
{item.description ? (
|
||||
<div className="typography-meta text-muted-foreground mt-0.5 line-clamp-2">{item.description}</div>
|
||||
) : (
|
||||
<div className="typography-meta text-muted-foreground/50 mt-0.5 italic">No description provided</div>
|
||||
<div className="typography-meta text-muted-foreground/50 mt-0.5 italic">{t('settings.skills.catalog.shared.noDescription')}</div>
|
||||
)}
|
||||
|
||||
{item.clawdhub && (
|
||||
<div className="typography-micro text-muted-foreground mt-1.5 flex items-center gap-3">
|
||||
{item.clawdhub.owner && (
|
||||
<span>by <span className="font-medium text-foreground/80">{item.clawdhub.owner}</span></span>
|
||||
<span>{t('settings.skills.catalog.page.byOwnerPrefix')} <span className="font-medium text-foreground/80">{item.clawdhub.owner}</span></span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<RiDownloadLine className="h-3 w-3" />
|
||||
@@ -337,7 +341,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
setInstallDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Install
|
||||
{t('settings.skills.catalog.shared.actions.install')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -356,7 +360,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
onClick={() => void loadMoreClawdHub()}
|
||||
disabled={isLoadingMore}
|
||||
>
|
||||
{isLoadingMore ? 'Loading...' : 'Load More Skills'}
|
||||
{isLoadingMore ? t('settings.skills.catalog.page.loading.more') : t('settings.skills.catalog.page.actions.loadMoreSkills')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -376,8 +380,8 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove Catalog</DialogTitle>
|
||||
<DialogDescription>Are you sure you want to remove this catalog?</DialogDescription>
|
||||
<DialogTitle>{t('settings.skills.catalog.page.removeDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('settings.skills.catalog.page.removeDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -386,10 +390,10 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
onClick={() => setIsRemoveCatalogDialogOpen(false)}
|
||||
disabled={isRemovingCatalog}
|
||||
>
|
||||
Cancel
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => void removeSelectedCatalog()} disabled={isRemovingCatalog}>
|
||||
Remove Catalog
|
||||
{t('settings.skills.catalog.page.actions.removeCatalog')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { PaceInfo } from '@/lib/quota';
|
||||
import { getPaceStatusColor, formatRemainingTime } from '@/lib/quota';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface PaceIndicatorProps {
|
||||
paceInfo: PaceInfo;
|
||||
@@ -19,22 +20,23 @@ export const PaceIndicator: React.FC<PaceIndicatorProps> = ({
|
||||
className,
|
||||
compact = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const statusColor = getPaceStatusColor(paceInfo.status);
|
||||
|
||||
const statusLabel = React.useMemo(() => {
|
||||
switch (paceInfo.status) {
|
||||
switch (paceInfo.status) {
|
||||
case 'on-track':
|
||||
return 'On track';
|
||||
return t('settings.usage.pace.status.onTrack');
|
||||
case 'slightly-fast':
|
||||
return 'Slightly fast';
|
||||
return t('settings.usage.pace.status.slightlyFast');
|
||||
case 'too-fast':
|
||||
return 'Too fast';
|
||||
return t('settings.usage.pace.status.tooFast');
|
||||
case 'exhausted':
|
||||
return 'Used up';
|
||||
}
|
||||
}, [paceInfo.status]);
|
||||
return t('settings.usage.pace.status.usedUp');
|
||||
}
|
||||
}, [paceInfo.status, t]);
|
||||
|
||||
const predictionTooltip = `Predicted usage at window end based on current pace: ${paceInfo.predictText}`;
|
||||
const predictionTooltip = t('settings.usage.pace.predictionTooltip', { prediction: paceInfo.predictText });
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
@@ -50,9 +52,9 @@ export const PaceIndicator: React.FC<PaceIndicatorProps> = ({
|
||||
title={paceInfo.isExhausted ? undefined : predictionTooltip}
|
||||
>
|
||||
{paceInfo.isExhausted ? (
|
||||
<>Wait {formatRemainingTime(paceInfo.remainingSeconds)}</>
|
||||
<>{t('settings.usage.pace.wait', { duration: formatRemainingTime(paceInfo.remainingSeconds) })}</>
|
||||
) : (
|
||||
<>Pred: {paceInfo.predictText}</>
|
||||
<>{t('settings.usage.pace.prediction', { prediction: paceInfo.predictText })}</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -64,7 +66,7 @@ export const PaceIndicator: React.FC<PaceIndicatorProps> = ({
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!paceInfo.isExhausted && (
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
Pace: {paceInfo.paceRateText}
|
||||
{t('settings.usage.pace.rate', { rate: paceInfo.paceRateText })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -76,12 +78,12 @@ export const PaceIndicator: React.FC<PaceIndicatorProps> = ({
|
||||
{paceInfo.isExhausted ? (
|
||||
<>
|
||||
<span className="font-medium">{statusLabel}</span>
|
||||
<span className="text-muted-foreground"> · Wait </span>
|
||||
<span className="text-muted-foreground">{t('settings.usage.pace.waitSeparator')}</span>
|
||||
<span className="font-medium">{formatRemainingTime(paceInfo.remainingSeconds)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span title={predictionTooltip}>
|
||||
<span className="text-muted-foreground">Pred: </span>
|
||||
<span className="text-muted-foreground">{t('settings.usage.pace.predictionLabel')}</span>
|
||||
<span className="font-medium">{paceInfo.predictText}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user