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
@@ -35,7 +35,7 @@ import {
import { cn } from '@/lib/utils';
import { isVSCodeRuntime } from '@/lib/desktop';
import { toast } from '@/components/ui';
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabel, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSession, useSessionPermissions } from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync';
import { useViewportStore } from '@/sync/viewport-store';
@@ -45,6 +45,7 @@ import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, r
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import { useSessionUnseenCount } from '@/sync/notification-store';
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
import { useI18n } from '@/lib/i18n';
type Folder = { id: string; name: string; sessionIds: string[] };
@@ -197,6 +198,7 @@ const areEqual = (prev: Props, next: Props): boolean => {
};
function SessionNodeItemComponent(props: Props): React.ReactNode {
const { t } = useI18n();
const {
node,
depth = 0,
@@ -297,7 +299,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
const directoryState = sessionDirectory ? directoryStatus.get(sessionDirectory) : null;
const isMissingDirectory = directoryState === 'missing';
const isActive = currentSessionId === session.id;
const sessionTitle = resolvedSession.title || 'Untitled Session';
const sessionTitle = resolvedSession.title || t('sessions.sidebar.session.untitled');
const hasChildren = node.children.length > 0;
const isPinnedSession = pinnedSessionIds.has(session.id);
const isExpanded = hasSessionSearchQuery ? true : expandedParents.has(session.id);
@@ -312,7 +314,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
const handleExportSession = React.useCallback(async () => {
if (!sessionDirectory) {
toast.error('Nothing to export');
toast.error(t('sessions.sidebar.session.export.nothingToExport'));
return;
}
@@ -320,7 +322,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
const records = buildSessionMessageRecordsSnapshot(directoryStore.getState(), session.id).list;
if (records.length === 0) {
toast.error('Nothing to export');
toast.error(t('sessions.sidebar.session.export.nothingToExport'));
return;
}
@@ -329,13 +331,13 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
const savedPath = await saveAsMarkdownDesktop(markdown, filename);
if (savedPath) {
toast.success('Session exported', {
toast.success(t('sessions.sidebar.session.export.success'), {
action: {
label: getExportRevealLabel(),
label: t(getExportRevealLabelKey()),
onClick: () => {
void revealExportedMarkdown(savedPath).then((revealed) => {
if (!revealed) {
toast.error('Failed to reveal path');
toast.error(t('sessions.sidebar.session.export.failedRevealPath'));
}
});
},
@@ -345,8 +347,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
}
downloadAsMarkdown(markdown, filename);
toast.success('Session exported');
}, [directoryStore, resolvedSession.title, session.id, sessionDirectory, sync]);
toast.success(t('sessions.sidebar.session.export.success'));
}, [directoryStore, resolvedSession.title, session.id, sessionDirectory, sync, t]);
if (editingId === session.id) {
return (
@@ -368,7 +370,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
onChange={(event) => setEditTitle(event.target.value)}
className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground"
autoFocus
placeholder="Rename session"
placeholder={t('sessions.sidebar.session.menu.rename')}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.stopPropagation();
@@ -408,15 +410,15 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
? (
<span
className="h-1.5 w-1.5 rounded-full bg-primary animate-busy-pulse"
aria-label="Session active"
title="Session active"
aria-label={t('sessions.sidebar.session.status.active')}
title={t('sessions.sidebar.session.status.active')}
/>
)
: (
<span
className="h-1.5 w-1.5 rounded-full bg-[var(--status-info)]"
aria-label="Unread updates"
title="Unread updates"
aria-label={t('sessions.sidebar.session.status.unread')}
title={t('sessions.sidebar.session.status.unread')}
/>
);
const inlineStatusMarker = !isMinimalMode && showStatusMarker ? (
@@ -455,7 +457,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto'
: '',
)}
aria-label={isExpanded ? 'Collapse subsessions' : 'Expand subsessions'}
aria-label={isExpanded
? t('sessions.sidebar.session.subsessions.collapse')
: t('sessions.sidebar.session.subsessions.expand')}
>
{isExpanded ? <RiArrowDownSLine className="h-3 w-3" /> : <RiArrowRightSLine className="h-3 w-3" />}
</span>
@@ -538,31 +542,33 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
className="[&>svg]:mr-1"
>
<RiPencilAiLine className="mr-1 h-4 w-4" />
Rename
{t('sessions.sidebar.session.menu.rename')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => togglePinnedSession(session.id)} className="[&>svg]:mr-1">
{isPinnedSession ? <RiUnpinLine className="mr-1 h-4 w-4" /> : <RiPushpinLine className="mr-1 h-4 w-4" />}
{isPinnedSession ? 'Unpin session' : 'Pin session'}
{isPinnedSession ? t('sessions.sidebar.session.menu.unpin') : t('sessions.sidebar.session.menu.pin')}
</DropdownMenuItem>
{!resolvedSession.share ? (
<DropdownMenuItem onClick={() => handleShareSession(resolvedSession)} className="[&>svg]:mr-1">
<RiShare2Line className="mr-1 h-4 w-4" />
Share
{t('sessions.sidebar.session.menu.share')}
</DropdownMenuItem>
) : (
<>
<DropdownMenuItem onClick={() => { if (resolvedSession.share?.url) handleCopyShareUrl(resolvedSession.share.url, session.id); }} className="[&>svg]:mr-1">
{copiedSessionId === session.id ? <><RiCheckLine className="mr-1 h-4 w-4" style={{ color: 'var(--status-success)' }} />Copied</> : <><RiFileCopyLine className="mr-1 h-4 w-4" />Copy link</>}
{copiedSessionId === session.id
? <><RiCheckLine className="mr-1 h-4 w-4" style={{ color: 'var(--status-success)' }} />{t('sessions.sidebar.session.menu.copied')}</>
: <><RiFileCopyLine className="mr-1 h-4 w-4" />{t('sessions.sidebar.session.menu.copyLink')}</>}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleUnshareSession(session.id)} className="[&>svg]:mr-1">
<RiLinkUnlinkM className="mr-1 h-4 w-4" />
Unshare
{t('sessions.sidebar.session.menu.unshare')}
</DropdownMenuItem>
</>
)}
<DropdownMenuItem onClick={() => { void handleExportSession(); }} className="[&>svg]:mr-1">
<RiDownloadLine className="mr-1 h-4 w-4" />
Export Markdown
{t('sessions.sidebar.session.menu.exportMarkdown')}
</DropdownMenuItem>
{sessionDirectory && !archivedBucket ? (() => {
@@ -572,10 +578,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
<>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger className="[&>svg]:mr-1"><RiFolderLine className="h-4 w-4" />Move to folder</DropdownMenuSubTrigger>
<DropdownMenuSubTrigger className="[&>svg]:mr-1"><RiFolderLine className="h-4 w-4" />{t('sessions.sidebar.folders.moveToFolder')}</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[180px]">
{scopeFolders.length === 0 ? (
<DropdownMenuItem disabled className="text-muted-foreground">No folders yet</DropdownMenuItem>
<DropdownMenuItem disabled className="text-muted-foreground">{t('sessions.sidebar.folders.none')}</DropdownMenuItem>
) : (
scopeFolders.map((folder) => (
<DropdownMenuItem key={folder.id} onClick={() => { if (currentFolderId === folder.id) removeSessionFromFolder(sessionDirectory, session.id); else addSessionToFolder(sessionDirectory, folder.id, session.id); }}>
@@ -587,12 +593,12 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => { const newFolder = createFolderAndStartRename(sessionDirectory); if (!newFolder) return; addSessionToFolder(sessionDirectory, newFolder.id, session.id); }}>
<RiAddLine className="mr-1 h-4 w-4" />
New folder...
{t('sessions.sidebar.folders.newFolderEllipsis')}
</DropdownMenuItem>
{currentFolderId ? (
<DropdownMenuItem onClick={() => { removeSessionFromFolder(sessionDirectory, session.id); }} className="text-destructive focus:text-destructive">
<RiCloseLine className="mr-1 h-4 w-4" />
Remove from folder
{t('sessions.sidebar.folders.removeFromFolder')}
</DropdownMenuItem>
) : null}
</DropdownMenuSubContent>
@@ -615,15 +621,15 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
className="[&>svg]:mr-1"
>
<RiChat4Line className="mr-1 h-4 w-4" />
<span className="truncate">Open in Side Panel</span>
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">beta</span>
<span className="truncate">{t('sessions.sidebar.session.menu.openInSidePanel')}</span>
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">{t('sessions.sidebar.session.menu.betaBadge')}</span>
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive [&>svg]:mr-1" onClick={() => handleDeleteSession(session, { archivedBucket })}>
<RiDeleteBinLine className="mr-1 h-4 w-4" />
{archivedBucket ? 'Delete' : 'Archive'}
{archivedBucket ? t('sessions.sidebar.bulkActions.delete') : t('sessions.sidebar.bulkActions.archive')}
</DropdownMenuItem>
</DropdownMenuContent>
);
@@ -669,7 +675,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
)}
>
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-1')}>
{isPinnedSession ? <RiPushpinLine className="h-3 w-3 flex-shrink-0 text-primary" aria-label="Pinned session" /> : null}
{isPinnedSession ? <RiPushpinLine className="h-3 w-3 flex-shrink-0 text-primary" aria-label={t('sessions.sidebar.session.status.pinned')} /> : null}
<div className={cn('block min-w-0 flex-1 truncate typography-ui-label font-normal', isActive ? 'text-primary' : 'text-foreground')}>{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
{mobileVariant ? <span className="ml-2 flex-shrink-0 text-[0.72rem] text-muted-foreground/75">{sessionCompactUpdatedLabel}</span> : null}
{!mobileVariant ? (
@@ -685,7 +691,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
</div>
) : null}
{pendingPermissionCount > 0 ? (
<span className="inline-flex items-center gap-1 rounded bg-destructive/10 px-1 py-0.5 text-[0.7rem] text-destructive flex-shrink-0" title="Permission required" aria-label="Permission required">
<span className="inline-flex items-center gap-1 rounded bg-destructive/10 px-1 py-0.5 text-[0.7rem] text-destructive flex-shrink-0" title={t('sessions.sidebar.session.status.permissionRequired')} aria-label={t('sessions.sidebar.session.status.permissionRequired')}>
<RiShieldLine className="h-3 w-3" />
<span className="leading-none">{pendingPermissionCount}</span>
</span>
@@ -735,10 +741,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
>
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-1')}>
{inlineStatusMarker}
{isPinnedSession ? <RiPushpinLine className="h-3 w-3 flex-shrink-0 text-primary" aria-label="Pinned session" /> : null}
{isPinnedSession ? <RiPushpinLine className="h-3 w-3 flex-shrink-0 text-primary" aria-label={t('sessions.sidebar.session.status.pinned')} /> : null}
<div className={cn('block min-w-0 flex-1 truncate typography-ui-label font-normal', isActive ? 'text-primary' : 'text-foreground')}>{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
{pendingPermissionCount > 0 ? (
<span className="inline-flex items-center gap-1 rounded bg-destructive/10 px-1 py-0.5 text-[0.7rem] text-destructive flex-shrink-0" title="Permission required" aria-label="Permission required">
<span className="inline-flex items-center gap-1 rounded bg-destructive/10 px-1 py-0.5 text-[0.7rem] text-destructive flex-shrink-0" title={t('sessions.sidebar.session.status.permissionRequired')} aria-label={t('sessions.sidebar.session.status.permissionRequired')}>
<RiShieldLine className="h-3 w-3" />
<span className="leading-none">{pendingPermissionCount}</span>
</span>
@@ -785,7 +791,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
: cn('h-4 w-4 opacity-0', revealOnHoverClass))
: 'h-6 w-6 opacity-100',
)}
aria-label="Session menu"
aria-label={t('sessions.sidebar.session.menu.label')}
onPointerDown={handleMenuTriggerPointerDown}
onMouseDown={handleMenuTriggerMouseDown}
onClick={handleMenuTriggerClick}