Add i18n foundation and translations (#1027)

* feat: add i18n foundation

* feat: localize sessions sidebar

* Localize multirun/scheduled tasks and fix dialog dropdown interactions

* localize git sidebar surface and add zh-CN keys

* feat(ui): localize context panel, diff/plan views, and context sidebar content

* fix(config): resolve user config home via fs/home before embedded home

* localize header/chat UI and complete model/worktree panel strings

* localize worktree + github issue/pr dialog flows

* localize settings sections and split settings i18n dictionaries

* localize additional settings sections and sidebars

* localize more settings pages and dialogs

* fix settings select trigger localization

* localize tunnel settings ui surface

* localize additional settings sections

* localize keyboard shortcuts labels in settings

* localize terminal and utility dialogs surfaces

* feat(i18n): localize remaining UI strings

* Add Ukrainian locale

* Add Spanish locale

* Add Brazilian Portuguese locale

* Polish locale translations
This commit is contained in:
Bohdan Triapitsyn
2026-04-26 14:03:39 +03:00
committed by GitHub
parent 87db2ea210
commit 7d7285655d
198 changed files with 24173 additions and 4365 deletions
@@ -2,6 +2,7 @@ import React from 'react';
import { RiCloseLine, RiFullscreenExitLine, RiFullscreenLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n';
const BOTTOM_DOCK_MIN_HEIGHT = 180;
const BOTTOM_DOCK_MAX_HEIGHT = 640;
@@ -14,6 +15,7 @@ interface BottomTerminalDockProps {
}
export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen, isMobile, children }) => {
const { t } = useI18n();
const bottomTerminalHeight = useUIStore((state) => state.bottomTerminalHeight);
const isFullscreen = useUIStore((state) => state.isBottomTerminalExpanded);
const setBottomTerminalHeight = useUIStore((state) => state.setBottomTerminalHeight);
@@ -153,7 +155,7 @@ export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen,
onPointerDown={handlePointerDown}
role="separator"
aria-orientation="horizontal"
aria-label="Resize terminal panel"
aria-label={t('terminalView.bottomDock.resizeAria')}
/>
)}
@@ -163,8 +165,8 @@ export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen,
type="button"
onClick={toggleFullscreen}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--surface-muted-foreground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
title={isFullscreen ? 'Restore terminal panel height' : 'Expand terminal panel'}
aria-label={isFullscreen ? 'Restore terminal panel height' : 'Expand terminal panel'}
title={isFullscreen ? t('terminalView.bottomDock.restoreTitle') : t('terminalView.bottomDock.expandTitle')}
aria-label={isFullscreen ? t('terminalView.bottomDock.restoreAria') : t('terminalView.bottomDock.expandAria')}
>
{isFullscreen ? <RiFullscreenExitLine className="h-5 w-5" /> : <RiFullscreenLine className="h-5 w-5" />}
</button>
@@ -172,8 +174,8 @@ export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen,
type="button"
onClick={() => setBottomTerminalOpen(false)}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--surface-muted-foreground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
title="Close terminal panel"
aria-label="Close terminal panel"
title={t('terminalView.bottomDock.closeTitle')}
aria-label={t('terminalView.bottomDock.closeAria')}
>
<RiCloseLine className="h-6 w-6" />
</button>
@@ -8,6 +8,7 @@ import { DiffView, FilesView, PlanView } from '@/components/views';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
import { useUIStore } from '@/stores/useUIStore';
import { ContextPanelContent } from './ContextSidebarTab';
@@ -16,6 +17,7 @@ const CONTEXT_PANEL_MIN_WIDTH = 360;
const CONTEXT_PANEL_MAX_WIDTH = 1400;
const CONTEXT_PANEL_DEFAULT_WIDTH = 600;
const CONTEXT_TAB_LABEL_MAX_CHARS = 24;
type TranslateFn = ReturnType<typeof useI18n>['t'];
const normalizeDirectoryKey = (value: string): string => {
if (!value) return '';
@@ -56,12 +58,15 @@ const getRelativePathLabel = (filePath: string | null, directory: string): strin
return normalizedFile;
};
const getModeLabel = (mode: 'diff' | 'file' | 'context' | 'plan' | 'chat'): string => {
if (mode === 'chat') return 'Chat';
if (mode === 'file') return 'Files';
if (mode === 'diff') return 'Diff';
if (mode === 'plan') return 'Plan';
return 'Context';
const getModeLabel = (
mode: 'diff' | 'file' | 'context' | 'plan' | 'chat',
t: TranslateFn
): string => {
if (mode === 'chat') return t('contextPanel.mode.chat');
if (mode === 'file') return t('contextPanel.mode.files');
if (mode === 'diff') return t('contextPanel.mode.diff');
if (mode === 'plan') return t('contextPanel.mode.plan');
return t('contextPanel.mode.context');
};
const getFileNameFromPath = (path: string | null): string | null => {
@@ -82,16 +87,19 @@ const getFileNameFromPath = (path: string | null): string | null => {
return segments[segments.length - 1] || null;
};
const getTabLabel = (tab: { mode: 'diff' | 'file' | 'context' | 'plan' | 'chat'; label: string | null; targetPath: string | null }): string => {
const getTabLabel = (
tab: { mode: 'diff' | 'file' | 'context' | 'plan' | 'chat'; label: string | null; targetPath: string | null },
t: TranslateFn
): string => {
if (tab.label) {
return tab.label;
}
if (tab.mode === 'file') {
return getFileNameFromPath(tab.targetPath) || 'Files';
return getFileNameFromPath(tab.targetPath) || t('contextPanel.mode.files');
}
return getModeLabel(tab.mode);
return getModeLabel(tab.mode, t);
};
const getTabIcon = (tab: { mode: 'diff' | 'file' | 'context' | 'plan' | 'chat'; targetPath: string | null }): React.ReactNode | undefined => {
@@ -156,6 +164,7 @@ const truncateTabLabel = (value: string, maxChars: number): string => {
};
export const ContextPanel: React.FC = () => {
const { t } = useI18n();
const effectiveDirectory = useEffectiveDirectory() ?? '';
const directoryKey = React.useMemo(() => normalizeDirectoryKey(effectiveDirectory), [effectiveDirectory]);
@@ -395,7 +404,7 @@ export const ContextPanel: React.FC = () => {
}, [darkThemeId, lightThemeId, postEmbeddedVisibilityToChats, postThemeSyncToEmbeddedChat, tabs, themeMode]);
const tabItems = React.useMemo(() => tabs.map((tab) => {
const rawLabel = getTabLabel(tab);
const rawLabel = getTabLabel(tab, t);
const label = truncateTabLabel(rawLabel, CONTEXT_TAB_LABEL_MAX_CHARS);
const tabPathLabel = getRelativePathLabel(tab.targetPath, effectiveDirectory);
return {
@@ -403,9 +412,9 @@ export const ContextPanel: React.FC = () => {
label,
icon: getTabIcon(tab),
title: tabPathLabel ? `${rawLabel}: ${tabPathLabel}` : rawLabel,
closeLabel: `Close ${label} tab`,
closeLabel: t('contextPanel.tab.closeTabAria', { label }),
};
}), [effectiveDirectory, tabs]);
}), [effectiveDirectory, t, tabs]);
const activeNonChatContent = activeTab?.mode === 'diff'
? <DiffView hideStackedFileSidebar stackedDefaultCollapsedAll hideFileSelector pinSelectedFileHeaderToTopOnNavigate showOpenInEditorAction />
@@ -459,8 +468,8 @@ export const ContextPanel: React.FC = () => {
size="sm"
onClick={handleToggleExpanded}
className="h-7 w-7 p-0"
title={isExpanded ? 'Collapse panel' : 'Expand panel'}
aria-label={isExpanded ? 'Collapse panel' : 'Expand panel'}
title={isExpanded ? t('contextPanel.actions.collapsePanel') : t('contextPanel.actions.expandPanel')}
aria-label={isExpanded ? t('contextPanel.actions.collapsePanel') : t('contextPanel.actions.expandPanel')}
>
{isExpanded ? <RiFullscreenExitLine className="h-3.5 w-3.5" /> : <RiFullscreenLine className="h-3.5 w-3.5" />}
</Button>
@@ -470,8 +479,8 @@ export const ContextPanel: React.FC = () => {
size="sm"
onClick={handleClose}
className="h-7 w-7 p-0"
title="Close panel"
aria-label="Close panel"
title={t('contextPanel.actions.closePanel')}
aria-label={t('contextPanel.actions.closePanel')}
>
<RiCloseLine className="h-3.5 w-3.5" />
</Button>
@@ -525,7 +534,7 @@ export const ContextPanel: React.FC = () => {
onPointerCancel={handleResizeEnd}
role="separator"
aria-orientation="vertical"
aria-label="Resize context panel"
aria-label={t('contextPanel.actions.resizePanelAria')}
/>
)}
{header}
@@ -557,7 +566,7 @@ export const ContextPanel: React.FC = () => {
chatFrameRefs.current.set(tab.id, node);
}}
src={src}
title={`Session chat ${sessionID}`}
title={t('contextPanel.iframe.sessionChatTitle', { sessionID })}
className={cn(
'absolute inset-0 h-full w-full border-0 bg-background',
activeChatTabID === tab.id ? 'block' : 'hidden'
@@ -10,6 +10,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessions, useSessionMessageRecords } from '@/sync/sync-context';
import { copyTextToClipboard } from '@/lib/clipboard';
import { useI18n } from '@/lib/i18n';
type SessionMessage = { info: Message; parts: Part[] };
@@ -230,14 +231,13 @@ const formatMoney = (value: number): string => {
const formatDateTime = (timestamp: number | null): string => {
if (!timestamp || !Number.isFinite(timestamp)) return '-';
const value = new Date(timestamp).toLocaleString(undefined, {
return new Date(timestamp).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
return value.replace(/, (\d{1,2}:\d{2} [AP]M)$/, ' at $1');
};
const formatMessageDateMeta = (timestamp: number | null): string => {
@@ -271,6 +271,7 @@ const resolveProviderAndModel = (
};
export const ContextPanelContent: React.FC = () => {
const { t } = useI18n();
const { currentTheme } = useThemeSystem();
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
const [expandedRawMessages, setExpandedRawMessages] = React.useState<Record<string, boolean>>({});
@@ -367,7 +368,7 @@ export const ContextPanelContent: React.FC = () => {
: null;
return {
sessionTitle: currentSession?.title || 'Untitled Session',
sessionTitle: currentSession?.title || t('contextSidebar.session.untitled'),
messagesCount: sessionMessages.length,
userMessagesCount: userMessages.length,
assistantMessagesCount: assistantMessages.length,
@@ -386,21 +387,21 @@ export const ContextPanelContent: React.FC = () => {
},
breakdownTotal,
};
}, [currentSessionId, providers, sessionMessages, sessions]);
}, [currentSessionId, providers, sessionMessages, sessions, t]);
if (!currentSessionId) {
return (
<div className="flex h-full items-center justify-center p-6 text-center typography-ui-label text-muted-foreground">
Open a session to inspect context.
<div className="flex h-full items-center justify-center p-6 text-center typography-ui-label text-muted-foreground">
{t('contextSidebar.empty.openSession')}
</div>
);
}
const segments: Array<{ key: string; label: string; value: number; color: string }> = [
{ key: 'user', label: 'User', value: viewModel.breakdown.user, color: 'var(--status-success)' },
{ key: 'assistant', label: 'Assistant', value: viewModel.breakdown.assistant, color: 'var(--primary-base)' },
{ key: 'tool', label: 'Tool Calls', value: viewModel.breakdown.tool, color: 'var(--status-warning)' },
{ key: 'other', label: 'Other', value: viewModel.breakdown.other, color: 'var(--surface-muted-foreground)' },
{ key: 'user', label: t('contextSidebar.breakdown.user'), value: viewModel.breakdown.user, color: 'var(--status-success)' },
{ key: 'assistant', label: t('contextSidebar.breakdown.assistant'), value: viewModel.breakdown.assistant, color: 'var(--primary-base)' },
{ key: 'tool', label: t('contextSidebar.breakdown.toolCalls'), value: viewModel.breakdown.tool, color: 'var(--status-warning)' },
{ key: 'other', label: t('contextSidebar.breakdown.other'), value: viewModel.breakdown.other, color: 'var(--surface-muted-foreground)' },
];
return (
@@ -424,7 +425,7 @@ export const ContextPanelContent: React.FC = () => {
{/* ── Context usage ── */}
<div className="mb-5 rounded-lg bg-[var(--surface-elevated)]/70 px-4 py-3.5">
<div className="flex items-baseline justify-between">
<span className="typography-micro text-muted-foreground">Context</span>
<span className="typography-micro text-muted-foreground">{t('contextSidebar.section.context')}</span>
<span className="typography-micro tabular-nums text-muted-foreground/70">
{formatNumber(viewModel.tokenBreakdown.total)}
{viewModel.contextLimit ? ` / ${formatNumber(viewModel.contextLimit)}` : ''}
@@ -442,17 +443,17 @@ export const ContextPanelContent: React.FC = () => {
)}
</div>
<div className="mt-1.5 typography-micro font-medium tabular-nums text-foreground/80">
{viewModel.usagePercent.toFixed(1)}% used
{t('contextSidebar.context.percentUsed', { percent: viewModel.usagePercent.toFixed(1) })}
</div>
</div>
{/* ── Stat grid ── */}
<div className="mb-5 grid grid-cols-2 gap-2">
{([
{ label: 'Messages', value: formatNumber(viewModel.messagesCount) },
{ label: 'User', value: formatNumber(viewModel.userMessagesCount) },
{ label: 'Assistant', value: formatNumber(viewModel.assistantMessagesCount) },
{ label: 'Cost', value: formatMoney(viewModel.totalAssistantCost) },
{ label: t('contextSidebar.stats.messages'), value: formatNumber(viewModel.messagesCount) },
{ label: t('contextSidebar.stats.user'), value: formatNumber(viewModel.userMessagesCount) },
{ label: t('contextSidebar.stats.assistant'), value: formatNumber(viewModel.assistantMessagesCount) },
{ label: t('contextSidebar.stats.cost'), value: formatMoney(viewModel.totalAssistantCost) },
] as const).map((item) => (
<div key={item.label} className="rounded-lg bg-[var(--surface-elevated)]/70 px-3 py-2.5">
<div className="typography-micro text-muted-foreground/70">{item.label}</div>
@@ -463,14 +464,14 @@ export const ContextPanelContent: React.FC = () => {
{/* ── Last turn tokens ── */}
<div className="mb-5 rounded-lg bg-[var(--surface-elevated)]/70 px-4 py-3.5">
<div className="typography-micro text-muted-foreground">Last Assistant Message</div>
<div className="typography-micro text-muted-foreground">{t('contextSidebar.section.lastAssistantMessage')}</div>
<div className="mt-2.5 grid grid-cols-3 gap-x-4 gap-y-2.5">
{([
{ label: 'Input', value: viewModel.tokenBreakdown.input },
{ label: 'Output', value: viewModel.tokenBreakdown.output },
{ label: 'Reasoning', value: viewModel.tokenBreakdown.reasoning },
{ label: 'Cache Read', value: viewModel.tokenBreakdown.cacheRead },
{ label: 'Cache Write', value: viewModel.tokenBreakdown.cacheWrite },
{ label: t('contextSidebar.tokens.input'), value: viewModel.tokenBreakdown.input },
{ label: t('contextSidebar.tokens.output'), value: viewModel.tokenBreakdown.output },
{ label: t('contextSidebar.tokens.reasoning'), value: viewModel.tokenBreakdown.reasoning },
{ label: t('contextSidebar.tokens.cacheRead'), value: viewModel.tokenBreakdown.cacheRead },
{ label: t('contextSidebar.tokens.cacheWrite'), value: viewModel.tokenBreakdown.cacheWrite },
] as const).map((item) => (
<div key={item.label}>
<div className="typography-micro text-muted-foreground/70">{item.label}</div>
@@ -513,7 +514,7 @@ export const ContextPanelContent: React.FC = () => {
{/* ── Raw messages ── */}
<div>
<div className="typography-micro text-muted-foreground">Raw Messages</div>
<div className="typography-micro text-muted-foreground">{t('contextSidebar.section.rawMessages')}</div>
<div className="mt-2.5 space-y-1">
{[...sessionMessages].reverse().map((message) => {
const role = deriveMessageRole(message.info).role;
@@ -561,8 +562,8 @@ export const ContextPanelContent: React.FC = () => {
event.stopPropagation();
void handleCopyRawMessage(message.info.id, jsonValue);
}}
aria-label={isCopied ? 'Copied' : 'Copy JSON'}
title={isCopied ? 'Copied' : 'Copy'}
aria-label={isCopied ? t('contextSidebar.actions.copied') : t('contextSidebar.actions.copyJson')}
title={isCopied ? t('contextSidebar.actions.copied') : t('contextSidebar.actions.copy')}
>
{isCopied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
</button>
+72 -59
View File
@@ -66,6 +66,7 @@ import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton';
import { isDesktopShell, isVSCodeRuntime, startDesktopWindowDrag } from '@/lib/desktop';
import { desktopHostsGet, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts';
import { resolveSessionDiffStats } from '@/components/session/sidebar/utils';
import { useI18n } from '@/lib/i18n';
import type { Session } from '@opencode-ai/sdk/v2/client';
const DESKTOP_HEADER_ICON_BUTTON_CLASS = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center gap-2 rounded-md typography-ui-label font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:bg-interactive-hover transition-colors';
@@ -132,6 +133,7 @@ const DesktopGitHubControl = React.memo(function DesktopGitHubControl({
isSwitchingGitHubAccount,
handleGitHubAccountSwitch,
}: DesktopGitHubControlProps) {
const { t } = useI18n();
if (!githubAuthStatus?.connected || isMobile) {
return null;
}
@@ -146,13 +148,13 @@ const DesktopGitHubControl = React.memo(function DesktopGitHubControl({
DESKTOP_HEADER_ICON_BUTTON_CLASS,
'h-7 w-7 overflow-hidden rounded-full border border-border/60 bg-muted/80 p-0'
)}
title={githubLogin ? `GitHub: ${githubLogin}` : 'GitHub connected'}
title={githubLogin ? t('header.github.connectedWithLogin', { login: githubLogin }) : t('header.github.connected')}
disabled={isSwitchingGitHubAccount}
>
{githubAvatarUrl ? (
<img
src={githubAvatarUrl}
alt={githubLogin ? `${githubLogin} avatar` : 'GitHub avatar'}
alt={githubLogin ? t('header.github.avatarWithLogin', { login: githubLogin }) : t('header.github.avatar')}
className="h-full w-full object-cover"
loading="lazy"
referrerPolicy="no-referrer"
@@ -164,7 +166,7 @@ const DesktopGitHubControl = React.memo(function DesktopGitHubControl({
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel className="typography-ui-header font-semibold text-foreground">
GitHub Accounts
{t('header.github.accountsTitle')}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{githubAccounts.map((account) => {
@@ -184,7 +186,7 @@ const DesktopGitHubControl = React.memo(function DesktopGitHubControl({
{accountUser?.avatarUrl ? (
<img
src={accountUser.avatarUrl}
alt={accountUser.login ? `${accountUser.login} avatar` : 'GitHub avatar'}
alt={accountUser.login ? t('header.github.avatarWithLogin', { login: accountUser.login }) : t('header.github.avatar')}
className="h-6 w-6 rounded-full border border-border/60 bg-muted object-cover"
loading="lazy"
referrerPolicy="no-referrer"
@@ -216,12 +218,12 @@ const DesktopGitHubControl = React.memo(function DesktopGitHubControl({
return (
<div
className="app-region-no-drag flex h-7 w-7 items-center justify-center overflow-hidden rounded-full border border-border/60 bg-muted/80"
title={githubLogin ? `GitHub: ${githubLogin}` : 'GitHub connected'}
title={githubLogin ? t('header.github.connectedWithLogin', { login: githubLogin }) : t('header.github.connected')}
>
{githubAvatarUrl ? (
<img
src={githubAvatarUrl}
alt={githubLogin ? `${githubLogin} avatar` : 'GitHub avatar'}
alt={githubLogin ? t('header.github.avatarWithLogin', { login: githubLogin }) : t('header.github.avatar')}
className="h-full w-full object-cover"
loading="lazy"
referrerPolicy="no-referrer"
@@ -284,6 +286,7 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
toggleFamilyExpanded,
shortcutLabel,
}: DesktopServicesMenuProps) {
const { t } = useI18n();
return (
<DropdownMenu
open={isDesktopServicesOpen}
@@ -303,8 +306,8 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
<button
type="button"
aria-label={isDesktopApp
? `Open instance, usage and MCP (current: ${currentInstanceLabel})`
: 'Open services, usage and MCP'}
? t('header.services.openWithCurrent', { current: currentInstanceLabel })
: t('header.services.open')}
className={cn(
DESKTOP_HEADER_ICON_BUTTON_CLASS,
isDesktopApp ? 'w-auto max-w-[14rem] justify-start gap-1.5 px-2.5' : 'h-8 w-8'
@@ -319,7 +322,16 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
</TooltipTrigger>
<TooltipContent>
<p>
{isDesktopApp ? `Current instance: ${currentInstanceLabel}` : 'Services'} ({shortcutLabel('toggle_services_menu')}; next tab {shortcutLabel('cycle_services_tab')})
{isDesktopApp
? t('header.services.tooltip.currentInstanceWithShortcuts', {
current: currentInstanceLabel,
toggle: shortcutLabel('toggle_services_menu'),
nextTab: shortcutLabel('cycle_services_tab'),
})
: t('header.services.tooltip.servicesWithShortcuts', {
toggle: shortcutLabel('toggle_services_menu'),
nextTab: shortcutLabel('cycle_services_tab'),
})}
</p>
</TooltipContent>
</Tooltip>
@@ -365,7 +377,7 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
<div className="overflow-x-hidden">
<div className="flex items-center justify-between gap-3 border-b border-[var(--interactive-border)] px-4 py-2.5">
<div className="flex min-w-0 items-baseline gap-2">
<span className="typography-ui-header font-semibold text-foreground">Rate limits</span>
<span className="typography-ui-header font-semibold text-foreground">{t('header.services.rateLimits')}</span>
<span className="truncate typography-micro text-muted-foreground">{formatTime(quotaLastUpdated)}</span>
</div>
<div className="flex items-center gap-1.5">
@@ -389,7 +401,7 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
)}
onClick={handleUsageRefresh}
disabled={isQuotaLoading || isUsageRefreshSpinning}
aria-label="Refresh rate limits"
aria-label={t('header.services.refreshRateLimitsAria')}
>
<RiRefreshLine className={cn('h-4 w-4', isUsageRefreshSpinning && 'animate-spin')} />
</button>
@@ -398,7 +410,7 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
{!hasRateLimits ? (
<div className="px-4 py-5 text-center">
<span className="typography-ui-label text-muted-foreground">No rate limits available.</span>
<span className="typography-ui-label text-muted-foreground">{t('header.services.noRateLimits')}</span>
</div>
) : null}
@@ -414,7 +426,7 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
</div>
{group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? (
<div className="px-4 pb-2">
<span className="typography-ui-label text-muted-foreground">{group.error ?? 'No rate limits reported.'}</span>
<span className="typography-ui-label text-muted-foreground">{group.error ?? t('header.services.noRateLimitsReported')}</span>
</div>
) : (
<div className="space-y-3 px-4 pb-2">
@@ -617,6 +629,7 @@ export const Header: React.FC<HeaderProps> = ({
rightDrawerOpen,
desktopRightSidebarActionsHost = null,
}) => {
const { t } = useI18n();
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
const toggleSidebar = useUIStore((state) => state.toggleSidebar);
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
@@ -904,7 +917,7 @@ export const Header: React.FC<HeaderProps> = ({
if (otherModels.length > 0) {
group.modelFamilies.push({
familyId: null,
familyLabel: 'Other',
familyLabel: t('header.services.modelFamily.other'),
models: otherModels,
});
}
@@ -1438,17 +1451,17 @@ export const Header: React.FC<HeaderProps> = ({
const tabs: TabConfig[] = React.useMemo(() => {
if (isMobile) {
const base: TabConfig[] = [
{ id: 'chat', label: 'Chat', icon: RiChat4Line },
{ id: 'chat', label: t('layout.mainTab.chat'), icon: RiChat4Line },
];
if (showPlanTab) {
base.push({ id: 'plan', label: 'Plan', icon: RiFileTextLine });
base.push({ id: 'plan', label: t('layout.mainTab.plan'), icon: RiFileTextLine });
}
base.push(
{ id: 'diff', label: 'Diff', icon: 'diff' },
{ id: 'files', label: 'Files', icon: RiFolder6Line },
{ id: 'terminal', label: 'Terminal', icon: RiTerminalBoxLine },
{ id: 'diff', label: t('layout.mainTab.diff'), icon: 'diff' },
{ id: 'files', label: t('layout.mainTab.files'), icon: RiFolder6Line },
{ id: 'terminal', label: t('layout.mainTab.terminal'), icon: RiTerminalBoxLine },
);
return base;
@@ -1456,7 +1469,7 @@ export const Header: React.FC<HeaderProps> = ({
// Desktop: no tabs in header
return [];
}, [isMobile, showPlanTab]);
}, [isMobile, showPlanTab, t]);
const shortcutLabel = React.useCallback((actionId: string) => {
return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides));
@@ -1471,14 +1484,14 @@ export const Header: React.FC<HeaderProps> = ({
const servicesTabs = React.useMemo(() => {
const base: Array<{ value: 'instance' | 'usage' | 'mcp'; label: string; icon: RemixiconComponentType }> = [];
if (isDesktopApp) {
base.push({ value: 'instance', label: 'Instance', icon: RiServerLine });
base.push({ value: 'instance', label: t('layout.services.instance'), icon: RiServerLine });
}
base.push(
{ value: 'usage', label: 'Usage', icon: RiTimerLine },
{ value: 'usage', label: t('layout.services.usage'), icon: RiTimerLine },
{ value: 'mcp', label: 'MCP', icon: McpIcon as unknown as RemixiconComponentType }
);
return base;
}, [isDesktopApp]);
}, [isDesktopApp, t]);
const servicesTabItems = React.useMemo(() => {
return servicesTabs.map((tab) => ({
@@ -1490,10 +1503,10 @@ export const Header: React.FC<HeaderProps> = ({
const quotaDisplayTabs = React.useMemo(() => {
return [
{ value: 'usage' as const, label: 'Used' },
{ value: 'remaining' as const, label: 'Remaining' },
{ value: 'usage' as const, label: t('header.services.used') },
{ value: 'remaining' as const, label: t('header.services.remaining') },
];
}, []);
}, [t]);
const quotaDisplayTabItems = React.useMemo(() => {
return quotaDisplayTabs.map((tab) => ({ id: tab.value, label: tab.label }));
@@ -1501,10 +1514,10 @@ export const Header: React.FC<HeaderProps> = ({
const mobileServicesTabItems = React.useMemo<SortableTabsStripItem[]>(() => {
return [
{ id: 'usage', label: 'Usage', icon: <RiTimerLine className="h-3.5 w-3.5" /> },
{ id: 'usage', label: t('layout.services.usage'), icon: <RiTimerLine className="h-3.5 w-3.5" /> },
{ id: 'mcp', label: 'MCP', icon: <RiCommandLine className="h-3.5 w-3.5" /> },
];
}, []);
}, [t]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -1633,17 +1646,17 @@ export const Header: React.FC<HeaderProps> = ({
{showPlanTab && (
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Open plan"
onClick={handleOpenContextPlan}
className={cn(desktopHeaderIconButtonClass, isContextPlanActive && 'bg-[var(--interactive-hover)]')}
>
<button
type="button"
aria-label={t('header.actions.openPlanAria')}
onClick={handleOpenContextPlan}
className={cn(desktopHeaderIconButtonClass, isContextPlanActive && 'bg-[var(--interactive-hover)]')}
>
<RiFileTextLine className="h-[18px] w-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Plan ({shortcutLabel('toggle_context_plan')})</p>
<p>{t('header.actions.planWithShortcut', { shortcut: shortcutLabel('toggle_context_plan') })}</p>
</TooltipContent>
</Tooltip>
)}
@@ -1674,14 +1687,14 @@ export const Header: React.FC<HeaderProps> = ({
shortcutLabel={shortcutLabel}
/>
<HeaderIconActionButton
title={`Terminal panel (${shortcutLabel('toggle_terminal')})`}
ariaLabel="Toggle terminal panel"
title={t('header.actions.terminalPanelWithShortcut', { shortcut: shortcutLabel('toggle_terminal') })}
ariaLabel={t('header.actions.toggleTerminalPanelAria')}
onClick={toggleBottomTerminal}
Icon={RiTerminalBoxLine}
/>
<HeaderIconActionButton
title={`Right sidebar (${shortcutLabel('toggle_right_sidebar')})`}
ariaLabel="Toggle right sidebar"
title={t('header.actions.rightSidebarWithShortcut', { shortcut: shortcutLabel('toggle_right_sidebar') })}
ariaLabel={t('header.actions.toggleRightSidebarAria')}
onClick={toggleRightSidebar}
Icon={RiLayoutRightLine}
/>
@@ -1709,12 +1722,12 @@ export const Header: React.FC<HeaderProps> = ({
)}
style={webWindowControlsOverlayStyle}
role="tablist"
aria-label="Main navigation"
aria-label={t('header.navigation.mainAria')}
>
<HeaderIconActionButton
visible={!isSidebarOpen}
title={`Open sessions (${shortcutLabel('toggle_sidebar')})`}
ariaLabel="Open sessions"
title={t('header.actions.openSessionsWithShortcut', { shortcut: shortcutLabel('toggle_sidebar') })}
ariaLabel={t('header.actions.openSessionsAria')}
onClick={handleOpenSessionSwitcher}
className={`${desktopHeaderIconButtonClass} shrink-0`}
Icon={RiLayoutLeftLine}
@@ -1726,7 +1739,7 @@ export const Header: React.FC<HeaderProps> = ({
<TooltipTrigger asChild>
<button
type="button"
aria-label="New session"
aria-label={t('header.actions.newSessionAria')}
onClick={handleHeaderNewSession}
className={cn(desktopHeaderIconButtonClass, 'mr-6 shrink-0')}
>
@@ -1734,7 +1747,7 @@ export const Header: React.FC<HeaderProps> = ({
</button>
</TooltipTrigger>
<TooltipContent>
<p>New session ({shortcutLabel('new_chat')})</p>
<p>{t('header.actions.newSessionWithShortcut', { shortcut: shortcutLabel('new_chat') })}</p>
</TooltipContent>
</Tooltip>
) : null}
@@ -1827,7 +1840,7 @@ export const Header: React.FC<HeaderProps> = ({
mobileHeaderIconButtonClass,
leftDrawerOpen && 'bg-interactive-selection text-interactive-selection-foreground'
)}
aria-label={leftDrawerOpen ? 'Close sessions' : 'Open sessions'}
aria-label={leftDrawerOpen ? t('header.actions.closeSessionsAria') : t('header.actions.openSessionsAria')}
>
<RiLayoutLeftLine className="h-5 w-5" />
</button>
@@ -1836,7 +1849,7 @@ export const Header: React.FC<HeaderProps> = ({
type="button"
onClick={() => setSessionSwitcherOpen(false)}
className="app-region-no-drag h-9 w-9 p-2 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-md active:bg-interactive-active"
aria-label="Back"
aria-label={t('header.actions.backAria')}
>
<RiArrowLeftSLine className="h-5 w-5" />
</button>
@@ -1845,14 +1858,14 @@ export const Header: React.FC<HeaderProps> = ({
type="button"
onClick={handleOpenSessionSwitcher}
className="app-region-no-drag h-9 w-9 p-2 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-md active:bg-interactive-active"
aria-label="Open sessions"
aria-label={t('header.actions.openSessionsAria')}
>
<RiPlayListAddLine className="h-5 w-5" />
</button>
)}
{isSessionSwitcherOpen && (
<span className="typography-ui-label font-semibold text-foreground">Sessions</span>
<span className="typography-ui-label font-semibold text-foreground">{t('header.sessions.title')}</span>
)}
</div>
@@ -1865,7 +1878,7 @@ export const Header: React.FC<HeaderProps> = ({
<div
className="flex items-center gap-0.5 rounded-lg bg-[var(--surface-muted)]/50 p-0.5"
role="tablist"
aria-label="Main navigation"
aria-label={t('header.navigation.mainAria')}
>
{tabs.map((tab) => {
const isActive = activeMainTab === tab.id;
@@ -1904,7 +1917,7 @@ export const Header: React.FC<HeaderProps> = ({
{tab.showDot && (
<span
className="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-primary"
aria-label="Changes available"
aria-label={t('header.changes.availableAria')}
/>
)}
</button>
@@ -1946,7 +1959,7 @@ export const Header: React.FC<HeaderProps> = ({
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="View services"
aria-label={t('header.services.viewAria')}
className={mobileHeaderIconButtonClass}
>
<RiStackLine className="h-5 w-5" />
@@ -1954,7 +1967,7 @@ export const Header: React.FC<HeaderProps> = ({
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Services</p>
<p>{t('header.services.title')}</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent
@@ -1987,7 +2000,7 @@ export const Header: React.FC<HeaderProps> = ({
type="button"
onClick={() => setIsMobileRateLimitsOpen(false)}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover"
aria-label="Close services"
aria-label={t('header.services.closeAria')}
>
<RiCloseLine className="h-5 w-5" />
</button>
@@ -2004,7 +2017,7 @@ export const Header: React.FC<HeaderProps> = ({
<div className="border-b border-[var(--interactive-border)]">
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="flex flex-col min-w-0 gap-0.5">
<span className="typography-ui-header font-semibold text-foreground">Rate limits</span>
<span className="typography-ui-header font-semibold text-foreground">{t('header.services.rateLimits')}</span>
<span className="truncate typography-micro text-muted-foreground">
{formatTime(quotaLastUpdated)}
</span>
@@ -2021,7 +2034,7 @@ export const Header: React.FC<HeaderProps> = ({
: 'text-muted-foreground hover:text-foreground'
)}
>
Used
{t('header.services.used')}
</button>
<span className="text-muted-foreground typography-ui-label px-0.5">·</span>
<button
@@ -2034,7 +2047,7 @@ export const Header: React.FC<HeaderProps> = ({
: 'text-muted-foreground hover:text-foreground'
)}
>
Remaining
{t('header.services.remaining')}
</button>
</div>
<button
@@ -2046,7 +2059,7 @@ export const Header: React.FC<HeaderProps> = ({
)}
onClick={handleUsageRefresh}
disabled={isQuotaLoading || isUsageRefreshSpinning}
aria-label="Refresh rate limits"
aria-label={t('header.services.refreshRateLimitsAria')}
>
<RiRefreshLine className={cn('h-4 w-4', isUsageRefreshSpinning && 'animate-spin')} />
</button>
@@ -2056,7 +2069,7 @@ export const Header: React.FC<HeaderProps> = ({
{!hasRateLimits && (
<div className="px-4 py-6 text-center">
<span className="typography-ui-label text-muted-foreground">No rate limits available.</span>
<span className="typography-ui-label text-muted-foreground">{t('header.services.noRateLimits')}</span>
</div>
)}
@@ -2077,7 +2090,7 @@ export const Header: React.FC<HeaderProps> = ({
{group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? (
<div className="px-4 pb-2">
<span className="typography-ui-label text-muted-foreground">
{group.error ?? 'No rate limits reported.'}
{group.error ?? t('header.services.noRateLimitsReported')}
</span>
</div>
) : (
@@ -20,6 +20,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { useDeviceInfo } from '@/lib/device';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { isDesktopShell } from '@/lib/desktop';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
@@ -63,6 +64,7 @@ const normalizeDirectoryKey = (value: string): string => {
};
export const MainLayout: React.FC = () => {
const { t } = useI18n();
const RIGHT_SIDEBAR_AUTO_CLOSE_WIDTH = 1140;
const RIGHT_SIDEBAR_AUTO_OPEN_WIDTH = 1220;
const BOTTOM_TERMINAL_AUTO_CLOSE_HEIGHT = 640;
@@ -696,7 +698,7 @@ export const MainLayout: React.FC = () => {
setMobileLeftDrawerOpen(false);
setRightSidebarOpen(false);
}}
aria-label="Close drawer"
aria-label={t('mainLayout.mobile.closeDrawerAria')}
/>
{/* Left drawer (Session) */}
@@ -22,6 +22,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { useTerminalStore } from '@/stores/useTerminalStore';
import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
import { openExternalUrl } from '@/lib/url';
import { useI18n } from '@/lib/i18n';
import {
getProjectActionsState,
type OpenChamberProjectAction,
@@ -154,10 +155,10 @@ const extractBestUrl = (value: string): string | null => {
return normalized[0] ?? null;
};
const formatActionButtonLabel = (value: string): string => {
const formatActionButtonLabel = (value: string, fallbackLabel: string): string => {
const trimmed = value.trim();
if (!trimmed) {
return 'Action';
return fallbackLabel;
}
const words = trimmed.split(/\s+/).filter(Boolean);
@@ -181,6 +182,7 @@ export const ProjectActionsButton = ({
compact = false,
allowMobile = false,
}: ProjectActionsButtonProps) => {
const { t } = useI18n();
const { terminal, runtime } = useRuntimeAPIs();
const { isMobile } = useDeviceInfo();
const isDesktopShellApp = React.useMemo(() => isDesktopShell(), []);
@@ -357,7 +359,7 @@ export const ProjectActionsButton = ({
if (maybeUrl) {
watch.openedUrl = true;
void openExternal(maybeUrl);
toast.success('Opened URL from action output');
toast.success(t('projectActions.toast.openedUrlFromOutput'));
}
urlWatchByRunKeyRef.current[runKey] = watch;
}
@@ -383,7 +385,7 @@ export const ProjectActionsButton = ({
const getOrCreateActionTab = React.useCallback(async (action: OpenChamberProjectAction) => {
if (!normalizedDirectory) {
throw new Error('No active directory');
throw new Error(t('projectActions.error.noActiveDirectory'));
}
const key = toProjectActionRunKey(normalizedDirectory, action.id);
@@ -430,7 +432,7 @@ export const ProjectActionsButton = ({
}
if (!normalizedDirectory) {
toast.error('No active directory for action');
toast.error(t('projectActions.error.noActiveDirectoryForAction'));
return;
}
@@ -458,7 +460,7 @@ export const ProjectActionsButton = ({
}
if (!activeSessionId) {
throw new Error('Failed to create terminal session');
throw new Error(t('projectActions.error.failedToCreateTerminalSession'));
}
if (createdSession) {
@@ -488,14 +490,14 @@ export const ProjectActionsButton = ({
if (desktopForwardUrl) {
void openExternal(desktopForwardUrl);
toast.success('Opened forwarded URL');
toast.success(t('projectActions.toast.openedForwardedUrl'));
} else if (manualOpenUrl) {
void openExternal(manualOpenUrl);
toast.success('Opened action URL');
toast.success(t('projectActions.toast.openedActionUrl'));
} else if (hasCustomOpenUrl) {
toast.error('Invalid custom URL format');
toast.error(t('projectActions.error.invalidCustomUrlFormat'));
} else if (hasDesktopForwardSelection) {
toast.error('Selected desktop SSH forward is unavailable');
toast.error(t('projectActions.error.selectedDesktopSshForwardUnavailable'));
}
urlWatchByRunKeyRef.current[key] = {
@@ -513,7 +515,7 @@ export const ProjectActionsButton = ({
return next;
});
delete urlWatchByRunKeyRef.current[runKey];
toast.error(error instanceof Error ? error.message : 'Failed to run action');
toast.error(error instanceof Error ? error.message : t('projectActions.error.failedToRunAction'));
}
}, [
desktopSshInstances,
@@ -645,7 +647,7 @@ export const ProjectActionsButton = ({
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
className
)}
aria-label="Add action"
aria-label={t('projectActions.actions.addActionAria')}
onClick={openProjectActionsSettings}
>
<RiAddLine className="h-5 w-5" />
@@ -666,7 +668,7 @@ export const ProjectActionsButton = ({
onClick={openProjectActionsSettings}
>
<RiAddLine className="h-4 w-4 text-muted-foreground" />
<span className="header-open-label whitespace-nowrap">Add action</span>
<span className="header-open-label whitespace-nowrap">{t('projectActions.actions.addAction')}</span>
</button>
);
}
@@ -678,7 +680,10 @@ export const ProjectActionsButton = ({
const selectedIconKey = (resolvedSelected.icon || 'play') as keyof typeof PROJECT_ACTION_ICON_MAP;
const SelectedIcon = PROJECT_ACTION_ICON_MAP[selectedIconKey] || RiPlayLine;
const selectedButtonLabel = formatActionButtonLabel(resolvedSelected.name);
const selectedButtonLabel = formatActionButtonLabel(
resolvedSelected.name,
t('projectActions.label.fallbackAction'),
);
const selectedRunKey = toProjectActionRunKey(normalizedDirectory, resolvedSelected.id);
const selectedRunning = runningByKey[selectedRunKey];
const isStoppingSelected = selectedRunning?.status === 'stopping';
@@ -697,7 +702,9 @@ export const ProjectActionsButton = ({
'disabled:cursor-not-allowed',
className
)}
aria-label={selectedRunning ? `Stop ${resolvedSelected.name}` : `Run ${resolvedSelected.name}`}
aria-label={selectedRunning
? t('projectActions.actions.stopNamedAria', { name: resolvedSelected.name })
: t('projectActions.actions.runNamedAria', { name: resolvedSelected.name })}
>
{isStoppingSelected
? <RiLoader4Line className="h-5 w-5 animate-spin text-[var(--status-warning)]" />
@@ -709,7 +716,7 @@ export const ProjectActionsButton = ({
<DropdownMenuContent align="end" className="w-52 max-h-[70vh] overflow-y-auto">
<DropdownMenuItem className="flex items-center gap-2" onClick={openProjectActionsSettings}>
<RiAddLine className="h-4 w-4" />
<span className="typography-ui-label text-foreground">Add new action</span>
<span className="typography-ui-label text-foreground">{t('projectActions.actions.addNewAction')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
{actions.map((entry) => {
@@ -762,7 +769,9 @@ export const ProjectActionsButton = ({
compact ? 'w-9 justify-center px-0' : 'gap-2 px-3',
'transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed'
)}
aria-label={selectedRunning ? `Stop ${resolvedSelected.name}` : `Run ${resolvedSelected.name}`}
aria-label={selectedRunning
? t('projectActions.actions.stopNamedAria', { name: resolvedSelected.name })
: t('projectActions.actions.runNamedAria', { name: resolvedSelected.name })}
>
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center">
{isStoppingSelected
@@ -783,7 +792,7 @@ export const ProjectActionsButton = ({
'border-l border-[var(--interactive-border)] text-muted-foreground',
'hover:bg-interactive-hover hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
)}
aria-label="Choose project action"
aria-label={t('projectActions.actions.chooseActionAria')}
>
<RiArrowDownSLine className="h-4 w-4" />
</button>
@@ -791,7 +800,7 @@ export const ProjectActionsButton = ({
<DropdownMenuContent align="center" className="w-52 max-h-[70vh] overflow-y-auto" style={{ translate: '-30px 0' }}>
<DropdownMenuItem className="flex items-center gap-2" onClick={openProjectActionsSettings}>
<RiAddLine className="h-4 w-4" />
<span className="typography-ui-label text-foreground">Add new action</span>
<span className="typography-ui-label text-foreground">{t('projectActions.actions.addNewAction')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
{actions.map((entry) => {
@@ -13,6 +13,7 @@ import { cn } from '@/lib/utils';
import { PROJECT_ICONS, PROJECT_COLORS, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useI18n } from '@/lib/i18n';
interface ProjectEditDialogProps {
open: boolean;
@@ -50,6 +51,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
initialIconBackground = null,
onSave,
}) => {
const { t } = useI18n();
const uploadProjectIcon = useProjectsStore((state) => state.uploadProjectIcon);
const removeProjectIcon = useProjectsStore((state) => state.removeProjectIcon);
const discoverProjectIcon = useProjectsStore((state) => state.discoverProjectIcon);
@@ -105,10 +107,10 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
const uploadResult = await uploadProjectIcon(projectId, pendingUploadIconFile);
setIsUploadingIcon(false);
if (!uploadResult.ok) {
toast.error(uploadResult.error || 'Failed to upload project icon');
toast.error(uploadResult.error || t('projectEditDialog.toast.failedToUploadIcon'));
return;
}
toast.success('Project icon updated');
toast.success(t('projectEditDialog.toast.iconUpdated'));
clearPendingUploadIcon();
setPendingRemoveImageIcon(false);
}
@@ -120,10 +122,10 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
const result = await removeProjectIcon(projectId);
setIsRemovingCustomIcon(false);
if (!result.ok) {
toast.error(result.error || 'Failed to remove project icon');
toast.error(result.error || t('projectEditDialog.toast.failedToRemoveIcon'));
return;
}
toast.success('Project icon removed');
toast.success(t('projectEditDialog.toast.iconRemoved'));
setPendingRemoveImageIcon(false);
setIconBackground(null);
}
@@ -213,37 +215,37 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
void discoverProjectIcon(projectId)
.then((result) => {
if (!result.ok) {
toast.error(result.error || 'Failed to discover project icon');
toast.error(result.error || t('projectEditDialog.toast.failedToDiscoverIcon'));
return;
}
if (result.skipped) {
toast.success('Custom icon already set for this project');
toast.success(t('projectEditDialog.toast.customIconAlreadySet'));
return;
}
toast.success('Project icon discovered');
toast.success(t('projectEditDialog.toast.iconDiscovered'));
})
.finally(() => {
setIsDiscoveringIcon(false);
});
}, [clearPendingUploadIcon, discoverProjectIcon, isDiscoveringIcon, projectId]);
}, [clearPendingUploadIcon, discoverProjectIcon, isDiscoveringIcon, projectId, t]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader className="min-w-0">
<DialogTitle>Edit project</DialogTitle>
<DialogTitle>{t('projectEditDialog.title')}</DialogTitle>
</DialogHeader>
<div className="min-w-0 space-y-5 py-1">
{/* Name */}
<div className="min-w-0 space-y-1.5">
<label className="typography-ui-label font-medium text-foreground">
Name
{t('projectEditDialog.field.name')}
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Project name"
placeholder={t('projectEditDialog.field.namePlaceholder')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
@@ -260,7 +262,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
{/* Color */}
<div className="min-w-0 space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Color
{t('projectEditDialog.field.color')}
</label>
<div className="flex gap-2 flex-wrap">
{/* No color option */}
@@ -273,7 +275,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
? 'border-foreground scale-110'
: 'border-border hover:border-border/80'
)}
title="None"
title={t('projectEditDialog.option.none')}
>
<span className="w-4 h-0.5 bg-muted-foreground/40 rotate-45 rounded-full" />
</button>
@@ -298,7 +300,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
{/* Icon */}
<div className="min-w-0 space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Icon
{t('projectEditDialog.field.icon')}
</label>
<input
ref={fileInputRef}
@@ -322,7 +324,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
? 'border-foreground scale-110 bg-[var(--surface-elevated)]'
: 'border-border hover:border-border/80'
)}
title="None"
title={t('projectEditDialog.option.none')}
>
<span className="w-4 h-0.5 bg-muted-foreground/40 rotate-45 rounded-full" />
</button>
@@ -351,7 +353,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
</div>
{effectiveHasImageIcon && iconPreviewUrl && (
<div className="flex items-center gap-2 pt-1">
<span className="typography-meta text-muted-foreground">Preview</span>
<span className="typography-meta text-muted-foreground">{t('projectEditDialog.field.preview')}</span>
<span className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-border/60 bg-[var(--surface-elevated)] p-1">
<span
className="inline-flex h-4 w-4 items-center justify-center overflow-hidden rounded-[2px]"
@@ -372,21 +374,21 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
{!hasCustomIcon && (
<>
<Button size="sm" variant="outline" onClick={() => fileInputRef.current?.click()} disabled={isUploadingIcon}>
{isUploadingIcon ? 'Uploading...' : 'Upload Icon'}
{isUploadingIcon ? t('projectEditDialog.actions.uploading') : t('projectEditDialog.actions.uploadIcon')}
</Button>
<Button size="sm" variant="outline" onClick={() => void handleDiscoverIcon()} disabled={isDiscoveringIcon}>
{isDiscoveringIcon ? 'Discovering...' : 'Discover Favicon'}
{isDiscoveringIcon ? t('projectEditDialog.actions.discovering') : t('projectEditDialog.actions.discoverFavicon')}
</Button>
</>
)}
{hasRemovableImageIcon && (
<Button size="sm" variant="outline" onClick={() => void handleRemoveImageIcon()} disabled={isRemovingCustomIcon}>
{isRemovingCustomIcon ? 'Removing...' : 'Remove Project Icon'}
{isRemovingCustomIcon ? t('projectEditDialog.actions.removing') : t('projectEditDialog.actions.removeProjectIcon')}
</Button>
)}
{pendingRemoveImageIcon && (
<Button size="sm" variant="outline" onClick={() => setPendingRemoveImageIcon(false)} disabled={isRemovingCustomIcon}>
Undo Remove
{t('projectEditDialog.actions.undoRemove')}
</Button>
)}
</div>
@@ -395,7 +397,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
{effectiveHasImageIcon && (
<div className="min-w-0 space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Icon Background
{t('projectEditDialog.field.iconBackground')}
</label>
<div className="flex flex-wrap items-center gap-2">
<input
@@ -403,7 +405,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
value={iconBackground ?? '#000000'}
onChange={(event) => setIconBackground(event.target.value)}
className="h-8 w-10 cursor-pointer rounded border border-border bg-transparent p-1"
aria-label="Project icon background color"
aria-label={t('projectEditDialog.field.iconBackgroundAria')}
/>
<Input
value={iconBackground ?? ''}
@@ -412,7 +414,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
className="h-8 w-[8.5rem]"
/>
<Button size="sm" variant="outline" onClick={() => setIconBackground(null)}>
Clear
{t('projectEditDialog.actions.clear')}
</Button>
</div>
</div>
@@ -421,10 +423,10 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
{t('projectEditDialog.actions.cancel')}
</Button>
<Button onClick={handleSave} disabled={!name.trim() || isUploadingIcon || isRemovingCustomIcon}>
Save
{t('projectEditDialog.actions.save')}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,6 +1,7 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n';
import { isDesktopShell, isVSCodeRuntime, startDesktopWindowDrag } from '@/lib/desktop';
export const RIGHT_SIDEBAR_CONTENT_WIDTH = 420;
@@ -15,6 +16,7 @@ interface RightSidebarProps {
}
export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children, className, onTopActionsHostChange }) => {
const { t } = useI18n();
const rightSidebarWidth = useUIStore((state) => state.rightSidebarWidth);
const setRightSidebarWidth = useUIStore((state) => state.setRightSidebarWidth);
const isDesktopApp = React.useMemo(() => isDesktopShell(), []);
@@ -182,7 +184,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children, cl
onPointerCancel={handlePointerEnd}
role="separator"
aria-orientation="vertical"
aria-label="Resize right panel"
aria-label={t('sidebar.resize.rightPanelAria')}
/>
)}
<div
@@ -11,6 +11,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { formatDirectoryName } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { SidebarFilesTree } from './SidebarFilesTree';
type RightTab = 'git' | 'files' | 'context';
@@ -90,6 +91,7 @@ const ContextSidebarPanel: React.FC = () => {
};
export const RightSidebarTabs: React.FC = () => {
const { t } = useI18n();
const rightSidebarTab = useUIStore((state) => state.rightSidebarTab);
const setRightSidebarTab = useUIStore((state) => state.setRightSidebarTab);
const isRightSidebarOpen = useUIStore((state) => state.isRightSidebarOpen);
@@ -100,20 +102,20 @@ export const RightSidebarTabs: React.FC = () => {
const tabItems = React.useMemo(() => [
{
id: 'git',
label: 'Git',
label: t('layout.rightSidebar.git'),
icon: <RiGitBranchLine className="h-3.5 w-3.5" />,
},
{
id: 'files',
label: 'Files',
label: t('layout.rightSidebar.files'),
icon: <RiFolder3Line className="h-3.5 w-3.5" />,
},
{
id: 'context',
label: 'Context',
label: t('layout.rightSidebar.context'),
icon: <RiBookletLine className="h-3.5 w-3.5" />,
},
], []);
], [t]);
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-sidebar">
@@ -1,6 +1,7 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { ErrorBoundary } from '../ui/ErrorBoundary';
import { useI18n } from '@/lib/i18n';
import { useUIStore } from '@/stores/useUIStore';
export const SIDEBAR_CONTENT_WIDTH = 280;
@@ -15,6 +16,7 @@ interface SidebarProps {
}
export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children, className }) => {
const { t } = useI18n();
const sidebarWidth = useUIStore((state) => state.sidebarWidth);
const setSidebarWidth = useUIStore((state) => state.setSidebarWidth);
const [isResizing, setIsResizing] = React.useState(false);
@@ -143,7 +145,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children, cl
onPointerCancel={handlePointerEnd}
role="separator"
aria-orientation="vertical"
aria-label="Resize left panel"
aria-label={t('sidebar.resize.leftPanelAria')}
/>
)}
<div
@@ -1,62 +0,0 @@
import React from 'react';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessions } from '@/sync/sync-context';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { cn } from '@/lib/utils';
interface SidebarContextSummaryProps {
className?: string;
}
const formatSessionTitle = (title?: string | null) => {
if (!title) {
return 'Untitled Session';
}
const trimmed = title.trim();
return trimmed.length > 0 ? trimmed : 'Untitled Session';
};
const formatDirectoryPath = (path?: string) => {
if (!path || path.length === 0) {
return '/';
}
return path;
};
export const SidebarContextSummary: React.FC<SidebarContextSummaryProps> = ({ className }) => {
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessions = useSessions();
const { currentDirectory } = useDirectoryStore();
const activeSessionTitle = React.useMemo(() => {
if (!currentSessionId) {
return 'No active session';
}
const session = sessions.find((item) => item.id === currentSessionId);
return session ? formatSessionTitle(session.title) : 'No active session';
}, [currentSessionId, sessions]);
const directoryFull = React.useMemo(() => {
return formatDirectoryPath(currentDirectory);
}, [currentDirectory]);
const directoryDisplay = React.useMemo(() => {
if (!directoryFull || directoryFull === '/') {
return directoryFull;
}
const segments = directoryFull.split('/').filter(Boolean);
return segments.length ? segments[segments.length - 1] : directoryFull;
}, [directoryFull]);
return (
<div className={cn('hidden min-h-[48px] flex-col justify-center gap-0.5 border-b bg-sidebar px-3 py-2 md:flex md:pb-2', className)}>
<span className="typography-meta text-muted-foreground">Session</span>
<span className="typography-ui-label font-semibold text-foreground truncate" title={activeSessionTitle}>
{activeSessionTitle}
</span>
<span className="typography-meta text-muted-foreground truncate" title={directoryFull}>
{directoryDisplay}
</span>
</div>
);
};
@@ -45,10 +45,11 @@ import { useGitStatus } from '@/stores/useGitStore';
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { copyTextToClipboard } from '@/lib/clipboard';
import { cn, getRevealLabel } from '@/lib/utils';
import { cn, getRevealLabelKey } from '@/lib/utils';
import { opencodeClient } from '@/lib/opencode/client';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
import { useI18n } from '@/lib/i18n';
type FileNode = {
name: string;
@@ -171,6 +172,7 @@ const FileRow: React.FC<FileRowProps> = ({
onRevealPath,
onOpenDialog,
}) => {
const { t } = useI18n();
const isDir = node.type === 'directory';
const { canRename, canCreateFile, canCreateFolder, canDelete, canReveal } = permissions;
@@ -256,32 +258,32 @@ const FileRow: React.FC<FileRowProps> = ({
<DropdownMenuContent align="end" side="bottom" onCloseAutoFocus={() => setContextMenuPath(null)}>
{canRename && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onOpenDialog('rename', node); }}>
<RiEditLine className="mr-2 h-4 w-4" /> Rename
<RiEditLine className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.rename')}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
void copyTextToClipboard(node.path).then((result) => {
if (result.ok) {
toast.success('Path copied');
toast.success(t('sidebarFilesTree.toast.pathCopied'));
return;
}
toast.error('Copy failed');
toast.error(t('sidebarFilesTree.toast.copyFailed'));
});
}}>
<RiFileCopyLine className="mr-2 h-4 w-4" /> Copy Path
<RiFileCopyLine className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.copyPath')}
</DropdownMenuItem>
{!isDir && downloadFile && (
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
void downloadFile(node.path);
}}>
<RiDownloadLine className="mr-2 h-4 w-4" /> Save
<RiDownloadLine className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.save')}
</DropdownMenuItem>
)}
{canReveal && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onRevealPath(node.path); }}>
<RiFolderReceivedLine className="mr-2 h-4 w-4" /> {getRevealLabel()}
<RiFolderReceivedLine className="mr-2 h-4 w-4" /> {t(getRevealLabelKey())}
</DropdownMenuItem>
)}
{isDir && (canCreateFile || canCreateFolder) && (
@@ -289,12 +291,12 @@ const FileRow: React.FC<FileRowProps> = ({
<DropdownMenuSeparator />
{canCreateFile && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onOpenDialog('createFile', node); }}>
<RiFileAddLine className="mr-2 h-4 w-4" /> New File
<RiFileAddLine className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.newFile')}
</DropdownMenuItem>
)}
{canCreateFolder && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onOpenDialog('createFolder', node); }}>
<RiFolderAddLine className="mr-2 h-4 w-4" /> New Folder
<RiFolderAddLine className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.newFolder')}
</DropdownMenuItem>
)}
</>
@@ -306,7 +308,7 @@ const FileRow: React.FC<FileRowProps> = ({
onClick={(e) => { e.stopPropagation(); onOpenDialog('delete', node); }}
className="text-destructive focus:text-destructive"
>
<RiDeleteBinLine className="mr-2 h-4 w-4" /> Delete
<RiDeleteBinLine className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.delete')}
</DropdownMenuItem>
</>
)}
@@ -321,6 +323,7 @@ const FileRow: React.FC<FileRowProps> = ({
// --- Main component ---
export const SidebarFilesTree: React.FC = () => {
const { t } = useI18n();
const { files, runtime } = useRuntimeAPIs();
const currentDirectory = useEffectiveDirectory() ?? '';
const root = normalizePath(currentDirectory.trim());
@@ -374,9 +377,9 @@ export const SidebarFilesTree: React.FC = () => {
const handleRevealPath = React.useCallback((targetPath: string) => {
if (!files.revealPath) return;
void files.revealPath(targetPath).catch(() => {
toast.error('Failed to reveal path');
toast.error(t('sidebarFilesTree.toast.revealFailed'));
});
}, [files]);
}, [files, t]);
const handleOpenDialog = React.useCallback((type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => {
setActiveDialog(type);
@@ -638,12 +641,12 @@ export const SidebarFilesTree: React.FC = () => {
if (activeDialog === 'createFile') {
if (!dialogInputValue.trim()) {
toast.error('Filename is required');
toast.error(t('sidebarFilesTree.toast.filenameRequired'));
done();
return;
}
if (!files.writeFile) {
toast.error('Write not supported');
toast.error(t('sidebarFilesTree.toast.writeNotSupported'));
done();
return;
}
@@ -655,19 +658,19 @@ export const SidebarFilesTree: React.FC = () => {
await files.writeFile(newPath, '')
.then(async (result) => {
if (result.success) {
toast.success('File created');
toast.success(t('sidebarFilesTree.toast.fileCreated'));
await refreshDirectory(parentPath);
}
closeDialog();
})
.catch(() => toast.error('Operation failed'))
.catch(() => toast.error(t('sidebarFilesTree.toast.operationFailed')))
.finally(done);
return;
}
if (activeDialog === 'createFolder') {
if (!dialogInputValue.trim()) {
toast.error('Folder name is required');
toast.error(t('sidebarFilesTree.toast.folderNameRequired'));
done();
return;
}
@@ -679,24 +682,24 @@ export const SidebarFilesTree: React.FC = () => {
await files.createDirectory(newPath)
.then(async (result) => {
if (result.success) {
toast.success('Folder created');
toast.success(t('sidebarFilesTree.toast.folderCreated'));
await refreshDirectory(parentPath);
}
closeDialog();
})
.catch(() => toast.error('Operation failed'))
.catch(() => toast.error(t('sidebarFilesTree.toast.operationFailed')))
.finally(done);
return;
}
if (activeDialog === 'rename') {
if (!dialogInputValue.trim()) {
toast.error('Name is required');
toast.error(t('sidebarFilesTree.toast.nameRequired'));
done();
return;
}
if (!files.rename) {
toast.error('Rename not supported');
toast.error(t('sidebarFilesTree.toast.renameNotSupported'));
done();
return;
}
@@ -709,7 +712,7 @@ export const SidebarFilesTree: React.FC = () => {
await files.rename(oldPath, newPath)
.then(async (result) => {
if (result.success) {
toast.success('Renamed successfully');
toast.success(t('sidebarFilesTree.toast.renamedSuccessfully'));
await refreshDirectory(parentDir);
if (root) {
removeOpenPathsByPrefix(root, oldPath);
@@ -720,14 +723,14 @@ export const SidebarFilesTree: React.FC = () => {
}
closeDialog();
})
.catch(() => toast.error('Operation failed'))
.catch(() => toast.error(t('sidebarFilesTree.toast.operationFailed')))
.finally(done);
return;
}
if (activeDialog === 'delete') {
if (!files.delete) {
toast.error('Delete not supported');
toast.error(t('sidebarFilesTree.toast.deleteNotSupported'));
done();
return;
}
@@ -737,7 +740,7 @@ export const SidebarFilesTree: React.FC = () => {
await files.delete(deletedPath)
.then(async (result) => {
if (result.success) {
toast.success('Deleted successfully');
toast.success(t('sidebarFilesTree.toast.deletedSuccessfully'));
await refreshDirectory(parentDir);
if (root) {
removeOpenPathsByPrefix(root, deletedPath);
@@ -748,13 +751,13 @@ export const SidebarFilesTree: React.FC = () => {
}
closeDialog();
})
.catch(() => toast.error('Operation failed'))
.catch(() => toast.error(t('sidebarFilesTree.toast.operationFailed')))
.finally(done);
return;
}
done();
}, [activeDialog, dialogData, dialogInputValue, files, refreshDirectory, removeOpenPathsByPrefix, root, selectedPath, setSelectedPath]);
}, [activeDialog, dialogData, dialogInputValue, files, refreshDirectory, removeOpenPathsByPrefix, root, selectedPath, setSelectedPath, t]);
// --- Tree rendering (matching FilesView with indent guides) ---
@@ -814,13 +817,13 @@ export const SidebarFilesTree: React.FC = () => {
ref={searchInputRef}
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search files..."
placeholder={t('sidebarFilesTree.search.placeholder')}
className="h-8 pl-8 pr-8 typography-meta"
/>
{searchQuery.trim().length > 0 ? (
<button
type="button"
aria-label="Clear search"
aria-label={t('sidebarFilesTree.search.clearAria')}
className="absolute right-2 top-2 inline-flex h-4 w-4 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() => {
setSearchQuery('');
@@ -837,7 +840,7 @@ export const SidebarFilesTree: React.FC = () => {
size="sm"
onClick={() => handleOpenDialog('createFile', { path: currentDirectory, type: 'directory' })}
className="h-8 w-8 p-0 flex-shrink-0"
title="New File"
title={t('sidebarFilesTree.actions.newFileTitle')}
>
<RiFileAddLine className="h-4 w-4" />
</Button>
@@ -848,12 +851,12 @@ export const SidebarFilesTree: React.FC = () => {
size="sm"
onClick={() => handleOpenDialog('createFolder', { path: currentDirectory, type: 'directory' })}
className="h-8 w-8 p-0 flex-shrink-0"
title="New Folder"
title={t('sidebarFilesTree.actions.newFolderTitle')}
>
<RiFolderAddLine className="h-4 w-4" />
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => void refreshRoot()} className="h-8 w-8 p-0 flex-shrink-0" title="Refresh">
<Button variant="ghost" size="sm" onClick={() => void refreshRoot()} className="h-8 w-8 p-0 flex-shrink-0" title={t('sidebarFilesTree.actions.refreshTitle')}>
<RiRefreshLine className="h-4 w-4" />
</Button>
</div>
@@ -863,7 +866,7 @@ export const SidebarFilesTree: React.FC = () => {
{searching ? (
<li className="flex items-center gap-1.5 px-2 py-1 typography-meta text-muted-foreground">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Searching...
{t('sidebarFilesTree.state.searching')}
</li>
) : searchResults.length > 0 ? (
searchResults.map((node) => {
@@ -900,7 +903,7 @@ export const SidebarFilesTree: React.FC = () => {
) : hasTree && root ? (
renderTree(root, 0)
) : (
<li className="px-2 py-1 typography-meta text-muted-foreground">Loading...</li>
<li className="px-2 py-1 typography-meta text-muted-foreground">{t('sidebarFilesTree.state.loading')}</li>
)}
</ul>
</ScrollableOverlay>
@@ -910,16 +913,16 @@ export const SidebarFilesTree: React.FC = () => {
<DialogContent>
<DialogHeader>
<DialogTitle>
{activeDialog === 'createFile' && 'Create File'}
{activeDialog === 'createFolder' && 'Create Folder'}
{activeDialog === 'rename' && 'Rename'}
{activeDialog === 'delete' && 'Delete'}
{activeDialog === 'createFile' && t('sidebarFilesTree.dialog.createFile.title')}
{activeDialog === 'createFolder' && t('sidebarFilesTree.dialog.createFolder.title')}
{activeDialog === 'rename' && t('sidebarFilesTree.dialog.rename.title')}
{activeDialog === 'delete' && t('sidebarFilesTree.dialog.delete.title')}
</DialogTitle>
<DialogDescription>
{activeDialog === 'createFile' && `Create a new file in ${dialogData?.path ?? 'root'}`}
{activeDialog === 'createFolder' && `Create a new folder in ${dialogData?.path ?? 'root'}`}
{activeDialog === 'rename' && `Rename ${dialogData?.name}`}
{activeDialog === 'delete' && `Are you sure you want to delete ${dialogData?.name}? This action cannot be undone.`}
{activeDialog === 'createFile' && t('sidebarFilesTree.dialog.createFile.description', { path: dialogData?.path ?? t('sidebarFilesTree.dialog.rootFallback') })}
{activeDialog === 'createFolder' && t('sidebarFilesTree.dialog.createFolder.description', { path: dialogData?.path ?? t('sidebarFilesTree.dialog.rootFallback') })}
{activeDialog === 'rename' && t('sidebarFilesTree.dialog.rename.description', { name: dialogData?.name ?? '' })}
{activeDialog === 'delete' && t('sidebarFilesTree.dialog.delete.description', { name: dialogData?.name ?? '' })}
</DialogDescription>
</DialogHeader>
@@ -928,7 +931,7 @@ export const SidebarFilesTree: React.FC = () => {
<Input
value={dialogInputValue}
onChange={(e) => setDialogInputValue(e.target.value)}
placeholder={activeDialog === 'rename' ? 'New name' : 'Name'}
placeholder={activeDialog === 'rename' ? t('sidebarFilesTree.dialog.rename.placeholder') : t('sidebarFilesTree.dialog.namePlaceholder')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
void handleDialogSubmit();
@@ -941,7 +944,7 @@ export const SidebarFilesTree: React.FC = () => {
<DialogFooter>
<Button variant="outline" onClick={() => setActiveDialog(null)} disabled={isDialogSubmitting}>
Cancel
{t('sidebarFilesTree.dialog.cancel')}
</Button>
<Button
variant={activeDialog === 'delete' ? 'destructive' : 'default'}
@@ -949,7 +952,7 @@ export const SidebarFilesTree: React.FC = () => {
disabled={isDialogSubmitting || (activeDialog !== 'delete' && !dialogInputValue.trim())}
>
{isDialogSubmitting ? <RiLoader4Line className="animate-spin" /> : (
activeDialog === 'delete' ? 'Delete' : 'Confirm'
activeDialog === 'delete' ? t('sidebarFilesTree.dialog.delete.confirm') : t('sidebarFilesTree.dialog.confirm')
)}
</Button>
</DialogFooter>
@@ -18,6 +18,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useI18n } from '@/lib/i18n';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar';
import { PaceIndicator } from '@/components/sections/usage/PaceIndicator';
@@ -54,6 +55,7 @@ const SESSIONS_SIDEBAR_MAX_WIDTH = 520;
type VSCodeView = 'sessions' | 'chat' | 'settings';
export const VSCodeLayout: React.FC = () => {
const { t } = useI18n();
const runtimeApis = useRuntimeAPIs();
const viewMode = React.useMemo<'sidebar' | 'editor'>(() => {
@@ -100,8 +102,8 @@ export const VSCodeLayout: React.FC = () => {
if (!currentSessionId) {
return null;
}
return sessions.find((session) => session.id === currentSessionId)?.title || 'Session';
}, [currentSessionId, sessions]);
return sessions.find((session) => session.id === currentSessionId)?.title || t('vscodeLayout.title.sessionFallback');
}, [currentSessionId, sessions, t]);
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
const isSyncingMessages = useViewportStore((state) => state.isSyncing);
const hasActiveSessionWork = useDirectorySync((state) => {
@@ -379,7 +381,7 @@ export const VSCodeLayout: React.FC = () => {
// Editor mode: just chat, no sidebar
<div className="flex flex-col h-full">
<VSCodeHeader
title={sessions.find((session) => session.id === currentSessionId)?.title || 'Chat'}
title={sessions.find((session) => session.id === currentSessionId)?.title || t('vscodeLayout.title.chat')}
showMcp
showContextUsage
/>
@@ -422,15 +424,15 @@ export const VSCodeLayout: React.FC = () => {
onPointerCancel={handleExpandedSidebarResizeEnd}
role="separator"
aria-orientation="vertical"
aria-label="Resize sessions sidebar"
aria-label={t('vscodeLayout.actions.resizeSessionsSidebarAria')}
/>
</div>
{/* Chat content */}
<div className="flex-1 flex flex-col min-w-0">
<VSCodeHeader
title={newSessionDraftOpen && !currentSessionId
? 'New session'
: sessions.find((session) => session.id === currentSessionId)?.title || 'Chat'}
? t('vscodeLayout.title.newSession')
: sessions.find((session) => session.id === currentSessionId)?.title || t('vscodeLayout.title.chat')}
showMcp
showContextUsage
/>
@@ -447,7 +449,7 @@ export const VSCodeLayout: React.FC = () => {
{/* Sessions list view */}
<div className={cn('flex flex-col h-full', currentView !== 'sessions' && 'hidden')}>
<VSCodeHeader
title="Sessions"
title={t('vscodeLayout.title.sessions')}
/>
<div className="flex-1 overflow-hidden">
<SessionSidebar
@@ -463,8 +465,8 @@ export const VSCodeLayout: React.FC = () => {
<div className={cn('flex flex-col h-full', currentView !== 'chat' && 'hidden')}>
<VSCodeHeader
title={newSessionDraftOpen && !currentSessionId
? 'New session'
: sessions.find((session) => session.id === currentSessionId)?.title || 'Chat'}
? t('vscodeLayout.title.newSession')
: sessions.find((session) => session.id === currentSessionId)?.title || t('vscodeLayout.title.chat')}
showBack
onBack={handleBackToSessions}
showMcp
@@ -496,6 +498,7 @@ interface VSCodeHeaderProps {
}
const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, onNewSession, onSettings, onAgentManager, showMcp, showContextUsage, showRateLimits }) => {
const { t } = useI18n();
const getCurrentModel = useConfigStore((s) => s.getCurrentModel);
const getContextUsage = useSessionUIStore((state) => state.getContextUsage);
const quotaResults = useQuotaStore((state) => state.results);
@@ -561,7 +564,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
<button
onClick={onBack}
className="inline-flex h-7 w-7 items-center justify-center text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label="Back to sessions"
aria-label={t('vscodeLayout.actions.backToSessionsAria')}
>
<RiArrowLeftLine className="h-5 w-5" />
</button>
@@ -571,7 +574,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
<button
onClick={onNewSession}
className="inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label="New session"
aria-label={t('vscodeLayout.actions.newSessionAria')}
>
<RiAddLine className="h-5 w-5" />
</button>
@@ -580,7 +583,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
<button
onClick={onAgentManager}
className="inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label="Open Agent Manager"
aria-label={t('vscodeLayout.actions.openAgentManagerAria')}
>
<RiRobot2Line className="h-5 w-5" />
</button>
@@ -601,7 +604,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="Rate limits"
aria-label={t('vscodeLayout.quota.actions.rateLimitsAria')}
className="inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
disabled={isQuotaLoading}
>
@@ -614,7 +617,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
>
<div className="sticky top-0 z-20 bg-[var(--surface-elevated)]">
<DropdownMenuLabel className="flex items-center justify-between gap-3 typography-ui-header font-semibold text-foreground">
<span>Rate limits</span>
<span>{t('vscodeLayout.quota.title')}</span>
<div className="flex items-center gap-1">
<div className="flex items-center rounded-md border border-[var(--interactive-border)] p-0.5">
<button
@@ -627,9 +630,9 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
}`
}
onClick={() => void handleDisplayModeChange('usage')}
aria-label="Show used quota"
aria-label={t('vscodeLayout.quota.actions.showUsedAria')}
>
Used
{t('vscodeLayout.quota.mode.used')}
</button>
<button
type="button"
@@ -641,9 +644,9 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
}`
}
onClick={() => void handleDisplayModeChange('remaining')}
aria-label="Show remaining quota"
aria-label={t('vscodeLayout.quota.actions.showRemainingAria')}
>
Remaining
{t('vscodeLayout.quota.mode.remaining')}
</button>
</div>
<button
@@ -651,7 +654,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
onClick={() => fetchAllQuotas()}
disabled={isQuotaLoading}
aria-label="Refresh rate limits"
aria-label={t('vscodeLayout.quota.actions.refreshAria')}
>
<RiRefreshLine className="h-4 w-4" />
</button>
@@ -659,11 +662,11 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
</DropdownMenuLabel>
</div>
<div className="border-b border-[var(--interactive-border)] px-2 pb-2 typography-micro text-muted-foreground text-[10px]">
Last updated {formatTime(quotaLastUpdated)}
{t('vscodeLayout.quota.lastUpdated', { time: formatTime(quotaLastUpdated) })}
</div>
{!hasRateLimits && (
<DropdownMenuItem className="cursor-default" closeOnClick={false}>
<span className="typography-ui-label text-muted-foreground">No rate limits available.</span>
<span className="typography-ui-label text-muted-foreground">{t('vscodeLayout.quota.noRateLimitsAvailable')}</span>
</DropdownMenuItem>
)}
{rateLimitGroups.map((group, index) => (
@@ -679,7 +682,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
closeOnClick={false}
>
<span className="typography-ui-label text-muted-foreground">
{group.error ?? 'No rate limits reported.'}
{group.error ?? t('vscodeLayout.quota.noRateLimitsReported')}
</span>
</DropdownMenuItem>
) : (
@@ -735,7 +738,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
<button
onClick={onSettings}
className="inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label="Settings"
aria-label={t('vscodeLayout.actions.settingsAria')}
>
<RiSettings3Line className="h-5 w-5" />
</button>