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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user