From 7d7285655d4e0b10124917f9d940573b2ec07c93 Mon Sep 17 00:00:00 2001
From: Bohdan Triapitsyn
{isRateLimit
- ? `Please wait ${minutes} minute${minutes > 1 ? 's' : ''} before trying again.`
- : "We couldn't verify the UI session. Check that the service is running and try again."}
+ ? (minutes > 1
+ ? t('sessionAuth.error.rateLimitDescriptionPlural', { minutes })
+ : t('sessionAuth.error.rateLimitDescriptionSingle', { minutes }))
+ : t('sessionAuth.error.networkDescription')}
{isTunnelLocked
- ? 'Open this tunnel using the one-time connect link from the desktop app.'
- : 'This session is password-protected.'}
+ ? t('sessionAuth.locked.tunnelDescription')
+ : t('sessionAuth.locked.passwordDescription')}
- Use Local if remote is unreachable.
+ {t('sessionAuth.locked.hostSwitcherHint')}
- The chat interface encountered an error. This might be due to a temporary network issue or corrupted message data.
+ {this.props.texts.description}
Attach files {t('chat.fileAttachment.actions.attach')}
- {isRateLimit ? 'Too many attempts' : 'Unable to reach server'}
+ {isRateLimit ? t('sessionAuth.error.rateLimitTitle') : t('sessionAuth.error.networkTitle')}
- {isTunnelLocked ? 'Tunnel access required' : 'Unlock OpenChamber'}
+ {isTunnelLocked ? t('sessionAuth.locked.tunnelTitle') : t('sessionAuth.locked.unlockTitle')}
Error details
+ {this.props.texts.detailsSummary}
{this.state.error.toString()}
@@ -70,12 +84,12 @@ export class ChatErrorBoundary extends React.Component
{workingDir}
+ {t('chat.permissionCard.workingDirectory')} {workingDir}
{String(genericContent)}
@@ -302,7 +304,7 @@ export const PermissionCard: React.FC = ({
{}
{Object.keys(permission.metadata).length > 0 && !genericContent && !description && (
- Details:
+ {t('chat.permissionCard.details')}
{JSON.stringify(permission.metadata, null, 2)}
@@ -343,7 +345,7 @@ export const PermissionCard: React.FC = ({
{permission.patterns.length > 0 && (
- Patterns:
+ {t('chat.permissionCard.patterns')}
{permission.patterns.join(", ")}
diff --git a/packages/ui/src/components/chat/PermissionRequest.tsx b/packages/ui/src/components/chat/PermissionRequest.tsx
index 402fb9cb..335d62c4 100644
--- a/packages/ui/src/components/chat/PermissionRequest.tsx
+++ b/packages/ui/src/components/chat/PermissionRequest.tsx
@@ -3,6 +3,7 @@ import { RiCheckLine, RiCloseLine, RiTimeLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import type { PermissionRequest as PermissionRequestPayload, PermissionResponse } from '@/types/permission';
import * as sessionActions from '@/sync/session-actions';
+import { useI18n } from '@/lib/i18n';
interface PermissionRequestProps {
permission: PermissionRequestPayload;
@@ -13,6 +14,7 @@ export const PermissionRequest: React.FC = ({
permission,
onResponse
}) => {
+ const { t } = useI18n();
const [isResponding, setIsResponding] = React.useState(false);
const [hasResponded, setHasResponded] = React.useState(false);
const respondToPermission = sessionActions.respondToPermission;;
@@ -42,7 +44,7 @@ export const PermissionRequest: React.FC = ({
- Permission required:
+ {t('chat.permissionRequest.required')}
{command}
@@ -70,7 +72,7 @@ export const PermissionRequest: React.FC = ({
}}
>
- Once
+ {t('chat.permissionRequest.actions.once')}
= ({
}}
>
- Always
+ {t('chat.permissionRequest.actions.always')}
= ({
}}
>
- Reject
+ {t('chat.permissionRequest.actions.reject')}
{isResponding && (
@@ -125,4 +127,4 @@ export const PermissionRequest: React.FC = ({
);
-};
\ No newline at end of file
+};
diff --git a/packages/ui/src/components/chat/PermissionToastActions.tsx b/packages/ui/src/components/chat/PermissionToastActions.tsx
index af61d175..60ebf802 100644
--- a/packages/ui/src/components/chat/PermissionToastActions.tsx
+++ b/packages/ui/src/components/chat/PermissionToastActions.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import { cn } from '@/lib/utils';
+import { useI18n } from '@/lib/i18n';
interface PermissionToastActionsProps {
sessionTitle: string;
@@ -27,10 +28,11 @@ export const PermissionToastActions: React.FC = ({
onAlways,
onDeny,
}) => {
+ const { t } = useI18n();
const [isBusy, setIsBusy] = React.useState(false);
- const actionContext = sessionTitle.trim().length > 0 ? ` for ${sessionTitle}` : '';
- const sessionPreview = truncateToastText(sessionTitle, 64) || 'Session';
- const permissionPreview = truncateToastText(permissionBody, 120) || 'Permission details unavailable';
+ const hasSessionTitle = sessionTitle.trim().length > 0;
+ const sessionPreview = truncateToastText(sessionTitle, 64) || t('chat.permissionToast.sessionFallback');
+ const permissionPreview = truncateToastText(permissionBody, 120) || t('chat.permissionToast.permissionFallback');
const handleAction = async (action: () => Promise | void) => {
if (isBusy || disabled) return;
@@ -46,13 +48,13 @@ export const PermissionToastActions: React.FC = ({
- Session:{' '}
+ {t('chat.permissionToast.labels.session')}{' '}
{sessionPreview}
- Permission:{' '}
+ {t('chat.permissionToast.labels.permission')}{' '}
{permissionPreview}
@@ -63,7 +65,9 @@ export const PermissionToastActions: React.FC = ({
handleAction(onOnce)}
disabled={disabled || isBusy}
- aria-label={`Approve once${actionContext}`}
+ aria-label={hasSessionTitle
+ ? t('chat.permissionToast.actions.approveOnceAriaWithSession', { session: sessionTitle })
+ : t('chat.permissionToast.actions.approveOnceAria')}
className={cn(
"px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
"disabled:opacity-50 disabled:cursor-not-allowed"
@@ -79,13 +83,15 @@ export const PermissionToastActions: React.FC = ({
e.currentTarget.style.backgroundColor = 'rgb(var(--status-success) / 0.1)';
}}
>
- Once
+ {t('chat.permissionToast.actions.once')}
handleAction(onAlways)}
disabled={disabled || isBusy}
- aria-label={`Approve always${actionContext}`}
+ aria-label={hasSessionTitle
+ ? t('chat.permissionToast.actions.approveAlwaysAriaWithSession', { session: sessionTitle })
+ : t('chat.permissionToast.actions.approveAlwaysAria')}
className={cn(
"px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
"disabled:opacity-50 disabled:cursor-not-allowed"
@@ -101,13 +107,15 @@ export const PermissionToastActions: React.FC = ({
e.currentTarget.style.backgroundColor = 'rgb(var(--muted) / 0.5)';
}}
>
- Always
+ {t('chat.permissionToast.actions.always')}
handleAction(onDeny)}
disabled={disabled || isBusy}
- aria-label={`Deny permission${actionContext}`}
+ aria-label={hasSessionTitle
+ ? t('chat.permissionToast.actions.denyAriaWithSession', { session: sessionTitle })
+ : t('chat.permissionToast.actions.denyAria')}
className={cn(
"px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
"disabled:opacity-50 disabled:cursor-not-allowed"
@@ -123,7 +131,7 @@ export const PermissionToastActions: React.FC = ({
e.currentTarget.style.backgroundColor = 'rgb(var(--status-error) / 0.1)';
}}
>
- Deny
+ {t('chat.permissionToast.actions.deny')}
diff --git a/packages/ui/src/components/chat/QuestionCard.tsx b/packages/ui/src/components/chat/QuestionCard.tsx
index d3e9adfe..8dc74e2e 100644
--- a/packages/ui/src/components/chat/QuestionCard.tsx
+++ b/packages/ui/src/components/chat/QuestionCard.tsx
@@ -8,6 +8,7 @@ import type { QuestionRequest } from '@/types/question';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessions } from '@/sync/sync-context';
import * as sessionActions from '@/sync/session-actions';
+import { useI18n } from '@/lib/i18n';
interface QuestionCardProps {
question: QuestionRequest;
@@ -17,6 +18,7 @@ type TabKey = string;
const SUMMARY_TAB = 'summary';
export const QuestionCard: React.FC = ({ question }) => {
+ const { t } = useI18n();
const respondToQuestion = sessionActions.respondToQuestion;
const rejectQuestion = sessionActions.rejectQuestion;;
const sessions = useSessions();
@@ -59,21 +61,21 @@ export const QuestionCard: React.FC = ({ question }) => {
}));
// Add summary tab when multiple questions
if (questions.length > 1) {
- questionTabs.push({ value: SUMMARY_TAB, label: 'Summary' });
+ questionTabs.push({ value: SUMMARY_TAB, label: t('chat.questionCard.summaryTab') });
}
return questionTabs;
- }, [questions]);
+ }, [questions, t]);
// Helper to get answer display for a question index
const getAnswerDisplay = React.useCallback((index: number): string => {
const isCustom = Boolean(customMode[index]);
if (isCustom) {
const value = (customText[index] ?? '').trim();
- return value || '(no answer)';
+ return value || t('chat.questionCard.noAnswer');
}
const answers = selectedOptions[index] ?? [];
- return answers.length > 0 ? answers.join(', ') : '(no answer)';
- }, [customMode, customText, selectedOptions]);
+ return answers.length > 0 ? answers.join(', ') : t('chat.questionCard.noAnswer');
+ }, [customMode, customText, selectedOptions, t]);
const isMultiple = Boolean(activeQuestion?.multiple);
const selectedForActive = selectedOptions[activeIndex] ?? [];
@@ -197,10 +199,10 @@ export const QuestionCard: React.FC = ({ question }) => {
- Input needed
+ {t('chat.questionCard.inputNeeded')}
{isFromSubagent ? (
- From subagent
+ {t('chat.questionCard.fromSubagent')}
) : null}
{activeHeader ? (
@@ -249,7 +251,7 @@ export const QuestionCard: React.FC = ({ question }) => {
{questions.map((q, index) => {
const answer = getAnswerDisplay(index);
- const hasAnswer = answer !== '(no answer)';
+ const hasAnswer = answer !== t('chat.questionCard.noAnswer');
return (
= ({ question }) => {
onClick={() => setActiveTab(String(index))}
className="w-full text-left rounded px-1.5 py-1 hover:bg-interactive-hover/20 transition-colors"
>
- {q.header || `Question ${index + 1}`}
+ {q.header || t('chat.questionCard.questionFallback', { index: index + 1 })}
= ({ question }) => {
{activeQuestion.question}
{isMultiple ? (
- Select multiple
+ {t('chat.questionCard.selectMultiple')}
) : null}
@@ -320,7 +322,7 @@ export const QuestionCard: React.FC = ({ question }) => {
{option.label}
{recommended ? (
- recommended
+ {t('chat.questionCard.recommended')}
) : null}
{option.description ? (
@@ -353,7 +355,7 @@ export const QuestionCard: React.FC = ({ question }) => {
'typography-meta',
isCustomActive ? 'text-foreground font-medium' : 'text-muted-foreground'
)}>
- Other…
+ {t('chat.questionCard.other')}
@@ -380,7 +382,7 @@ export const QuestionCard: React.FC = ({ question }) => {
el.style.height = `${Math.min(Math.max(el.scrollHeight, minHeight), maxHeight)}px`;
setCustomText((prev) => ({ ...prev, [activeIndex]: el.value }));
}}
- placeholder="Your answer"
+ placeholder={t('chat.questionCard.yourAnswer')}
disabled={isResponding}
rows={2}
className="w-full bg-transparent border border-border/30 focus:border-primary rounded px-2 py-1 outline-none typography-meta text-foreground placeholder:text-muted-foreground/50 transition-colors resize-none overflow-hidden"
@@ -406,7 +408,7 @@ export const QuestionCard: React.FC = ({ question }) => {
)}
>
{requiredSatisfied ? : }
- {requiredSatisfied ? 'Submit' : 'Next'}
+ {requiredSatisfied ? t('chat.questionCard.submit') : t('chat.questionCard.next')}
= ({ question }) => {
)}
>
- Dismiss
+ {t('chat.questionCard.dismiss')}
{isResponding ? (
diff --git a/packages/ui/src/components/chat/QueuedMessageChips.tsx b/packages/ui/src/components/chat/QueuedMessageChips.tsx
index 31fd5cce..45fbecd7 100644
--- a/packages/ui/src/components/chat/QueuedMessageChips.tsx
+++ b/packages/ui/src/components/chat/QueuedMessageChips.tsx
@@ -3,6 +3,7 @@ import { RiCloseLine, RiMessage2Line } from '@remixicon/react';
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useInputStore } from '@/sync/input-store';
+import { useI18n } from '@/lib/i18n';
interface QueuedMessageChipProps {
message: QueuedMessage;
@@ -11,6 +12,7 @@ interface QueuedMessageChipProps {
}
const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChipProps) => {
+ const { t } = useI18n();
const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue);
// Get first line of message, truncated
@@ -38,11 +40,11 @@ const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChi
Queued
{attachmentCount > 0 && (
- +{attachmentCount} file{attachmentCount > 1 ? 's' : ''}
+ {t('chat.queuedMessage.attachments', { count: attachmentCount })}
)}
- {firstLine || '(empty)'}
+ {firstLine || t('chat.queuedMessage.empty')}
{
@@ -50,7 +52,7 @@ const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChi
removeFromQueue(sessionId, message.id);
}}
className="flex items-center justify-center h-6 w-6 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
- aria-label="Remove from queue"
+ aria-label={t('chat.queuedMessage.removeAria')}
>
diff --git a/packages/ui/src/components/chat/StatusRow.tsx b/packages/ui/src/components/chat/StatusRow.tsx
index 039e5ff2..7a329b6e 100644
--- a/packages/ui/src/components/chat/StatusRow.tsx
+++ b/packages/ui/src/components/chat/StatusRow.tsx
@@ -22,6 +22,7 @@ import { useTodosPersistStore } from "@/stores/useTodosPersistStore";
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
import { isVSCodeRuntime } from "@/lib/desktop";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { useI18n } from "@/lib/i18n";
const statusConfig: Record = {
in_progress: {
@@ -50,17 +51,17 @@ const priorityIcon: Record = {
low: ,
};
-const statusLabel: Record = {
- in_progress: "In progress",
- pending: "Pending",
- completed: "Completed",
- cancelled: "Cancelled",
+const statusLabelKey: Record = {
+ in_progress: "chat.statusRow.todo.status.inProgress",
+ pending: "chat.statusRow.todo.status.pending",
+ completed: "chat.statusRow.todo.status.completed",
+ cancelled: "chat.statusRow.todo.status.cancelled",
};
-const priorityLabel: Record = {
- high: "High priority",
- medium: "Medium priority",
- low: "Low priority",
+const priorityLabelKey: Record = {
+ high: "chat.statusRow.todo.priority.high",
+ medium: "chat.statusRow.todo.priority.medium",
+ low: "chat.statusRow.todo.priority.low",
};
interface TodoItemRowProps {
@@ -68,7 +69,10 @@ interface TodoItemRowProps {
}
const TodoItemRow: React.FC = ({ todo }) => {
+ const { t } = useI18n();
const config = statusConfig[todo.status] || statusConfig.pending;
+ const statusKey = statusLabelKey[todo.status] ?? statusLabelKey.pending;
+ const priorityKey = priorityLabelKey[todo.priority] ?? priorityLabelKey.medium;
const statusIcon =
todo.status === "in_progress" ? (
@@ -86,7 +90,7 @@ const TodoItemRow: React.FC = ({ todo }) => {
{statusIcon}
- {statusLabel[todo.status] ?? statusLabel.pending}
+ {t(statusKey as never)}
= ({ todo }) => {
- {priorityLabel[todo.priority] ?? priorityLabel.medium}
+ {t(priorityKey as never)}
@@ -154,6 +158,7 @@ export const StatusRow: React.FC = ({
agentName,
leftAccessory,
}) => {
+ const { t } = useI18n();
const [isExpanded, setIsExpanded] = React.useState(false);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const todosRecord = useDirectorySync((state) => state.todo);
@@ -235,7 +240,7 @@ export const StatusRow: React.FC = ({
type="button"
onClick={onAbort}
className="flex items-center justify-center h-[1.2rem] w-[1.2rem] text-[var(--status-error)] transition-opacity hover:opacity-80 focus-visible:outline-none flex-shrink-0"
- aria-label="Stop generating"
+ aria-label={t('chat.statusRow.actions.stopGeneratingAria')}
>
@@ -254,10 +259,10 @@ export const StatusRow: React.FC = ({
{activeTodo.content}
) : (
- Tasks
+ {t('chat.statusRow.tasksTitle')}
)}
- {statusSummary.active} active · {statusSummary.left} left
+ {t('chat.statusRow.summary.activeLeft', { active: statusSummary.active, left: statusSummary.left })}
{isExpanded ? (
@@ -281,7 +286,7 @@ export const StatusRow: React.FC = ({
- Aborted
+ {t('chat.statusRow.aborted')}
) : showAssistantStatus && shouldRenderPlaceholder ? (
@@ -323,7 +328,7 @@ export const StatusRow: React.FC = ({
>
{/* Header */}
- Tasks
+ {t('chat.statusRow.tasksTitle')}
{progress.completed}/{progress.total}
diff --git a/packages/ui/src/components/chat/TimelineDialog.tsx b/packages/ui/src/components/chat/TimelineDialog.tsx
index 5db0f35a..49db6faf 100644
--- a/packages/ui/src/components/chat/TimelineDialog.tsx
+++ b/packages/ui/src/components/chat/TimelineDialog.tsx
@@ -12,6 +12,7 @@ import { useSessionMessageRecords } from '@/sync/sync-context';
import { RiLoader4Line, RiSearchLine, RiTimeLine, RiGitBranchLine, RiArrowGoBackLine } from '@remixicon/react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import type { Part } from '@opencode-ai/sdk/v2';
+import { useI18n } from '@/lib/i18n';
interface TimelineDialogProps {
open: boolean;
@@ -21,22 +22,6 @@ interface TimelineDialogProps {
onResumeToLatest?: () => void;
}
-// Helper: format relative time (e.g., "2 hours ago")
-function formatRelativeTime(timestamp: number): string {
- const now = Date.now();
- const diffMs = now - timestamp;
- const diffSecs = Math.floor(diffMs / 1000);
- const diffMins = Math.floor(diffSecs / 60);
- const diffHours = Math.floor(diffMins / 60);
- const diffDays = Math.floor(diffHours / 24);
-
- if (diffSecs < 60) return 'just now';
- if (diffMins < 60) return `${diffMins}m ago`;
- if (diffHours < 24) return `${diffHours}h ago`;
- if (diffDays < 7) return `${diffDays}d ago`;
- return new Date(timestamp).toLocaleDateString();
-}
-
export const TimelineDialog: React.FC = ({
open,
onOpenChange,
@@ -44,6 +29,7 @@ export const TimelineDialog: React.FC = ({
onScrollByTurnOffset,
onResumeToLatest,
}) => {
+ const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const messages = useSessionMessageRecords(currentSessionId ?? '');
const revertToMessage = useSessionUIStore((state) => state.revertToMessage);
@@ -52,6 +38,21 @@ export const TimelineDialog: React.FC = ({
const [forkingMessageId, setForkingMessageId] = React.useState(null);
const [searchQuery, setSearchQuery] = React.useState('');
+ const formatRelativeTime = React.useCallback((timestamp: number): string => {
+ const now = Date.now();
+ const diffMs = now - timestamp;
+ const diffSecs = Math.floor(diffMs / 1000);
+ const diffMins = Math.floor(diffSecs / 60);
+ const diffHours = Math.floor(diffMins / 60);
+ const diffDays = Math.floor(diffHours / 24);
+
+ if (diffSecs < 60) return t('chat.timeline.relative.justNow');
+ if (diffMins < 60) return t('chat.timeline.relative.minutesAgo', { count: diffMins });
+ if (diffHours < 24) return t('chat.timeline.relative.hoursAgo', { count: diffHours });
+ if (diffDays < 7) return t('chat.timeline.relative.daysAgo', { count: diffDays });
+ return new Date(timestamp).toLocaleDateString();
+ }, [t]);
+
// Filter user messages (reversed for newest first)
const userMessages = React.useMemo(() => {
const filtered = messages.filter(m => m.info.role === 'user');
@@ -89,17 +90,17 @@ export const TimelineDialog: React.FC = ({
- Conversation Timeline
+ {t('chat.timeline.title')}
- Navigate to any point in the conversation or fork a new session
+ {t('chat.timeline.description')}
setSearchQuery(e.target.value)}
className="pl-9 w-full"
@@ -109,7 +110,7 @@ export const TimelineDialog: React.FC = ({
{filteredMessages.length === 0 ? (
- {searchQuery ? 'No messages found' : 'No messages in this session yet'}
+ {searchQuery ? t('chat.timeline.empty.search') : t('chat.timeline.empty.session')}
) : (
filteredMessages.map((message) => {
@@ -134,7 +135,7 @@ export const TimelineDialog: React.FC = ({
{messageNumber}.
- {preview || '[No text content]'}
+ {preview || t('chat.timeline.noTextContent')}
{preview && preview.length >= 80 && '…'}
@@ -158,7 +159,7 @@ export const TimelineDialog: React.FC = ({
- Revert from here
+ {t('chat.timeline.actions.revertFromHere')}
@@ -179,7 +180,7 @@ export const TimelineDialog: React.FC = ({
)}
- Fork from here
+ {t('chat.timeline.actions.forkFromHere')}
@@ -190,7 +191,7 @@ export const TimelineDialog: React.FC = ({
- Actions
+ {t('chat.timeline.actions.title')}
= ({
onOpenChange(false);
}}
>
- Previous turn
+ {t('chat.timeline.actions.previousTurn')}
/
= ({
onOpenChange(false);
}}
>
- Latest
+ {t('chat.timeline.actions.latest')}
- Click on a message to scroll to it in the conversation
+ {t('chat.timeline.help.clickMessage')}
- Undo to this point (message text will populate input)
+ {t('chat.timeline.help.undoToPoint')}
- Create a new session starting from here
+ {t('chat.timeline.help.createSessionFromHere')}
diff --git a/packages/ui/src/components/chat/TurnChangedFilesDropdown.tsx b/packages/ui/src/components/chat/TurnChangedFilesDropdown.tsx
index 1b04166c..61f92201 100644
--- a/packages/ui/src/components/chat/TurnChangedFilesDropdown.tsx
+++ b/packages/ui/src/components/chat/TurnChangedFilesDropdown.tsx
@@ -25,6 +25,8 @@ interface TurnChangedFilesDropdownProps {
export const TurnChangedFilesDropdown: React.FC = React.memo(({ activityParts }) => {
const [isExpanded, setIsExpanded] = React.useState(false);
+ const [portalContainer, setPortalContainer] = React.useState(null);
+ const triggerButtonRef = React.useRef(null);
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
const runtime = React.useContext(RuntimeAPIContext);
const isGitRepo = useIsGitRepo(currentDirectory);
@@ -46,6 +48,11 @@ export const TurnChangedFilesDropdown: React.FC =
if (changedFiles.length === 0) return null;
+ const syncPortalContainer = () => {
+ const container = triggerButtonRef.current?.closest('[data-slot="dialog-content"], [role="dialog"]') as HTMLElement | null;
+ setPortalContainer(container || null);
+ };
+
const handleOpenFile = (file: ChangedFileEntry) => {
if (!currentDirectory) return;
if (isGitFile(file)) return;
@@ -82,9 +89,12 @@ export const TurnChangedFilesDropdown: React.FC =
{label}
@@ -99,7 +109,7 @@ export const TurnChangedFilesDropdown: React.FC =
{label} changed in this turn
-
+
= ({
onOpenModel,
onOpenEffort,
}) => {
+ const { t } = useI18n();
const providers = useConfigStore((state) => state.providers);
const currentProviderId = useConfigStore((state) => state.currentProviderId);
const currentModelId = useConfigStore((state) => state.currentModelId);
@@ -156,16 +158,16 @@ export const UnifiedControlsDrawer: React.FC = ({
};
return (
-
+
- Model
+ {t('chat.unifiedControls.model.title')}
{recentModels.length === 0 && !hasCurrentInRecents && (
- No recent models
+ {t('chat.unifiedControls.model.noRecent')}
)}
{recentModels.map(({ providerID, modelID, model }) => {
@@ -204,7 +206,7 @@ export const UnifiedControlsDrawer: React.FC = ({
type="button"
onClick={onOpenModel}
className="flex min-h-[44px] w-full items-center justify-center border-t border-border/30 px-3 py-2 typography-meta font-medium text-muted-foreground"
- aria-label="More models"
+ aria-label={t('chat.unifiedControls.model.moreAria')}
>
...
@@ -214,7 +216,7 @@ export const UnifiedControlsDrawer: React.FC = ({
{hasEffort && (
- Effort
+ {t('chat.unifiedControls.effort.title')}
{quickEfforts.map((variant) => {
@@ -241,7 +243,7 @@ export const UnifiedControlsDrawer: React.FC = ({
type="button"
onClick={onOpenEffort}
className="inline-flex items-center rounded-full border border-border/40 px-2.5 py-1 typography-meta font-medium text-muted-foreground hover:bg-interactive-hover/50"
- aria-label="More effort options"
+ aria-label={t('chat.unifiedControls.effort.moreAria')}
>
...
diff --git a/packages/ui/src/components/chat/components/ScrollToBottomButton.tsx b/packages/ui/src/components/chat/components/ScrollToBottomButton.tsx
index c3234ae4..814c4ce1 100644
--- a/packages/ui/src/components/chat/components/ScrollToBottomButton.tsx
+++ b/packages/ui/src/components/chat/components/ScrollToBottomButton.tsx
@@ -3,6 +3,7 @@ import { RiArrowDownLine } from '@remixicon/react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
+import { useI18n } from '@/lib/i18n';
interface ScrollToBottomButtonProps {
visible: boolean;
@@ -10,6 +11,7 @@ interface ScrollToBottomButtonProps {
}
const ScrollToBottomButton: React.FC = ({ visible, onClick }) => {
+ const { t } = useI18n();
return (
= ({ visible, on
size="sm"
onClick={onClick}
className="size-8 rounded-full [corner-shape:round] p-0 shadow-none bg-background/95 hover:bg-interactive-hover"
- aria-label="Scroll to bottom"
+ aria-label={t('chat.scrollToBottom.aria')}
>
diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx
index 4c6b7852..d167d058 100644
--- a/packages/ui/src/components/chat/message/MessageBody.tsx
+++ b/packages/ui/src/components/chat/message/MessageBody.tsx
@@ -42,6 +42,7 @@ import { createProjectPlanFile } from '@/lib/openchamberConfig';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useSessions } from '@/sync/sync-context';
+import { useI18n } from '@/lib/i18n';
type SubtaskPartLike = Part & {
type: 'subtask';
@@ -86,6 +87,7 @@ const normalizeSubtaskModel = (model: SubtaskPartLike['model']): string | null =
const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
const [expanded, setExpanded] = React.useState(false);
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
+ const { t } = useI18n();
const description = typeof part.description === 'string' ? part.description.trim() : '';
const command = typeof part.command === 'string' ? part.command.trim() : '';
@@ -97,7 +99,7 @@ const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
return (
- Delegated task
+ {t('chat.messageBody.subtask.title')}
{command ? (
/{command}
@@ -128,7 +130,7 @@ const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
className="typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
onClick={() => setExpanded((value) => !value)}
>
- {expanded ? 'Hide prompt' : 'Show prompt'}
+ {expanded ? t('chat.messageBody.subtask.hidePrompt') : t('chat.messageBody.subtask.showPrompt')}
{expanded ? (
@@ -147,7 +149,7 @@ const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
void setCurrentSession(taskSessionID);
}}
>
- Open subtask session
+ {t('chat.messageBody.subtask.openSession')}
) : null}
@@ -159,6 +161,7 @@ const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part })
const [expanded, setExpanded] = React.useState(false);
const [copiedOutput, setCopiedOutput] = React.useState(false);
const copiedResetTimeoutRef = React.useRef(null);
+ const { t } = useI18n();
const command = typeof part.shellAction?.command === 'string' ? part.shellAction.command.trim() : '';
const output = typeof part.shellAction?.output === 'string' ? part.shellAction.output : '';
@@ -197,7 +200,7 @@ const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part })
return (
- Shell command
+ {t('chat.messageBody.shellCommand.title')}
{status ? (
= ({ part })
className="typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
onClick={() => setExpanded((value) => !value)}
>
- {expanded ? 'Hide output' : 'Show output'}
+ {expanded ? t('chat.messageBody.shellCommand.hideOutput') : t('chat.messageBody.shellCommand.showOutput')}
= ({ part })
onClick={() => {
void copyOutputToClipboard();
}}
- aria-label={copiedOutput ? 'Copied' : 'Copy output'}
- title={copiedOutput ? 'Copied' : 'Copy output'}
+ aria-label={copiedOutput ? t('chat.messageBody.shellCommand.copied') : t('chat.messageBody.shellCommand.copyOutput')}
+ title={copiedOutput ? t('chat.messageBody.shellCommand.copied') : t('chat.messageBody.shellCommand.copyOutput')}
>
{copiedOutput ? : }
@@ -332,6 +335,7 @@ const UserMessageBody: React.FC<{
userActionsMode?: 'inline' | 'external-content' | 'external-actions';
stickyUserHeaderEnabled?: boolean;
}> = ({ messageId, parts, isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork, userActionsMode = 'inline', stickyUserHeaderEnabled = true }) => {
+ const { t } = useI18n();
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
const copyHintTimeoutRef = React.useRef(null);
@@ -441,7 +445,7 @@ const UserMessageBody: React.FC<{
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
- aria-label="Revert to this message"
+ aria-label={t('chat.messageBody.actions.revertAria')}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
@@ -451,7 +455,7 @@ const UserMessageBody: React.FC<{
- Revert from here
+ {t('chat.messageBody.actions.revert')}
)}
{onFork && (
@@ -462,7 +466,7 @@ const UserMessageBody: React.FC<{
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
- aria-label="Fork from this message"
+ aria-label={t('chat.messageBody.actions.forkAria')}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
@@ -472,7 +476,7 @@ const UserMessageBody: React.FC<{
- Fork from here
+ {t('chat.messageBody.actions.fork')}
)}
{canCopyMessage && hasCopyableText && (
@@ -484,7 +488,7 @@ const UserMessageBody: React.FC<{
size="icon"
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
- aria-label="Copy message text"
+ aria-label={t('chat.messageBody.actions.copyMessageAria')}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleCopyButtonClick}
onFocus={() => setCopyHintVisible(true)}
@@ -501,7 +505,7 @@ const UserMessageBody: React.FC<{
)}
- Copy message
+ {t('chat.messageBody.actions.copyMessage')}
)}
@@ -596,6 +600,7 @@ const AssistantMessageBody: React.FC> = ({
turnGroupingContext,
errorMessage,
}) => {
+ const { t } = useI18n();
const streamPhase = _streamPhase;
void _allowAnimation;
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
@@ -729,11 +734,11 @@ const AssistantMessageBody: React.FC> = ({
const readAloudTooltip = React.useMemo(() => {
if (isTTSPlaying) {
- return 'Stop speaking';
+ return t('chat.messageBody.tts.stopSpeaking');
}
const providerLabel = voiceProvider === 'browser' ? 'Browser' : voiceProvider === 'openai' ? 'OpenAI' : voiceProvider === 'openai-compatible' ? 'Custom' : 'Say';
- return `Read aloud (${providerLabel} voice)`;
- }, [isTTSPlaying, voiceProvider]);
+ return t('chat.messageBody.tts.readAloudWithProvider', { provider: providerLabel });
+ }, [isTTSPlaying, t, voiceProvider]);
const currentSession = React.useMemo(() => {
if (!currentSessionId) {
@@ -979,7 +984,7 @@ const AssistantMessageBody: React.FC> = ({
return;
}
if (!currentProjectRef) {
- toast.error('No project found for this session');
+ toast.error(t('chat.messageBody.toast.noProject'));
return;
}
@@ -990,14 +995,14 @@ const AssistantMessageBody: React.FC> = ({
body: assistantPlanText,
});
if (!created) {
- toast.error('Failed to save plan');
+ toast.error(t('chat.messageBody.toast.savePlanFailed'));
return;
}
window.dispatchEvent(new CustomEvent('openchamber:project-plan-saved', {
detail: { projectId: currentProjectRef.id },
}));
setIsPlanDialogOpen(false);
- toast.success('Plan saved');
+ toast.success(t('chat.messageBody.toast.planSaved'));
} finally {
setIsSavingPlan(false);
}
@@ -1104,10 +1109,10 @@ const AssistantMessageBody: React.FC> = ({
document.body.removeChild(link);
}
- toast.success('Image saved');
+ toast.success(t('chat.messageBody.toast.imageSaved'));
} catch (error) {
console.error('Failed to generate image:', error);
- toast.error('Failed to generate image');
+ toast.error(t('chat.messageBody.toast.generateImageFailed'));
} finally {
if (wrapper && wrapper.parentNode) {
wrapper.parentNode.removeChild(wrapper);
@@ -1424,7 +1429,7 @@ const AssistantMessageBody: React.FC> = ({
!hasCopyableText && 'opacity-50'
)}
disabled={!hasCopyableText}
- aria-label="Copy message text"
+ aria-label={t('chat.messageBody.actions.copyMessageAria')}
aria-hidden={!hasCopyableText}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleCopyButtonClick}
@@ -1446,7 +1451,7 @@ const AssistantMessageBody: React.FC> = ({
)}
- Copy answer
+ {t('chat.messageBody.actions.copyAnswer')}
)}
@@ -1470,7 +1475,7 @@ const AssistantMessageBody: React.FC> = ({
)}
- {isSharing ? 'Saving image...' : 'Save as image'}
+ {isSharing ? t('chat.messageBody.actions.savingImage') : t('chat.messageBody.actions.saveAsImage')}
{!isVSCodeRuntime() ? (
@@ -1490,7 +1495,7 @@ const AssistantMessageBody: React.FC> = ({
- Save as plan
+ {t('chat.messageBody.actions.saveAsPlan')}
) : null}
@@ -1506,7 +1511,7 @@ const AssistantMessageBody: React.FC> = ({
- Start new session from this answer
+ {t('chat.messageBody.actions.startNewSession')}
@@ -1521,7 +1526,7 @@ const AssistantMessageBody: React.FC> = ({
- Start new multi-run from this answer
+ {t('chat.messageBody.actions.startNewMultiRun')}
{showMessageTTSButtons && hasCopyableText && (
@@ -1535,7 +1540,7 @@ const AssistantMessageBody: React.FC> = ({
'h-8 w-8 bg-transparent hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
isTTSPlaying ? 'text-green-500' : 'text-muted-foreground hover:text-foreground'
)}
- aria-label={isTTSPlaying ? 'Stop speaking' : 'Read aloud'}
+ aria-label={isTTSPlaying ? t('chat.messageBody.tts.stopSpeaking') : t('chat.messageBody.tts.readAloud')}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleTTSClick}
>
diff --git a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx
index 03c44f95..97b97ef1 100644
--- a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx
+++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx
@@ -14,6 +14,7 @@ import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { summarizeText } from '@/lib/voice/summarize';
import { isVSCodeRuntime } from '@/lib/desktop';
+import { useI18n } from '@/lib/i18n';
interface TextSelectionMenuProps {
containerRef: React.RefObject;
@@ -206,6 +207,7 @@ const rangeToMarkdown = (range: Range, plainText: string): string => {
};
export const TextSelectionMenu: React.FC = ({ containerRef }) => {
+ const { t } = useI18n();
const [position, setPosition] = React.useState({ x: 0, y: 0, show: false });
const [selectedText, setSelectedText] = React.useState('');
const [selectedTextMarkdown, setSelectedTextMarkdown] = React.useState('');
@@ -498,7 +500,7 @@ export const TextSelectionMenu: React.FC = ({ containerR
const handleAddToNotes = React.useCallback(async () => {
if (!selectedText || !currentProjectRef) {
if (!currentProjectRef) {
- toast.error('No project found for this session');
+ toast.error(t('chat.textSelection.toast.noProject'));
}
return;
}
@@ -517,18 +519,18 @@ export const TextSelectionMenu: React.FC = ({ containerR
todos: projectData.todos,
});
if (!saved) {
- toast.error('Failed to add to notes');
+ toast.error(t('chat.textSelection.toast.addToNotesFailed'));
return;
}
window.dispatchEvent(new CustomEvent('openchamber:project-notes-updated', {
detail: { projectId: currentProjectRef.id },
}));
- toast.success('Added distilled insight to notes');
+ toast.success(t('chat.textSelection.toast.addToNotesSuccess'));
hideMenu();
window.getSelection()?.removeAllRanges();
} catch (error) {
const description = error instanceof Error ? error.message : undefined;
- toast.error('Failed to add to notes', description ? { description } : undefined);
+ toast.error(t('chat.textSelection.toast.addToNotesFailed'), description ? { description } : undefined);
} finally {
setIsAddingToNotes(false);
}
@@ -566,7 +568,7 @@ export const TextSelectionMenu: React.FC = ({ containerR
type="button"
>
- Add to chat
+ {t('chat.textSelection.actions.addToChat')}
= ({ containerR
type="button"
>
- New session
+ {t('chat.textSelection.actions.newSession')}
= ({ containerR
type="button"
>
- Copy
+ {t('chat.textSelection.actions.copy')}
{!isVSCodeRuntime() ? (
@@ -613,7 +615,7 @@ export const TextSelectionMenu: React.FC = ({ containerR
type="button"
>
{isAddingToNotes ? : }
- Add to notes
+ {t('chat.textSelection.actions.addToNotes')}
) : null}
,
@@ -651,11 +653,11 @@ export const TextSelectionMenu: React.FC = ({ containerR
'hover:bg-[var(--interactive-hover)]',
'transition-colors duration-150'
)}
- title="Add to current chat"
+ title={t('chat.textSelection.title.addToCurrentChat')}
type="button"
>
- Add to chat
+ {t('chat.textSelection.actions.addToChat')}
@@ -669,11 +671,11 @@ export const TextSelectionMenu: React.FC = ({ containerR
'hover:bg-[var(--interactive-hover)]',
'transition-colors duration-150'
)}
- title="Create new session with selection"
+ title={t('chat.textSelection.title.newSessionWithSelection')}
type="button"
>
- New session
+ {t('chat.textSelection.actions.newSession')}
{!isVSCodeRuntime() ? (
@@ -690,11 +692,11 @@ export const TextSelectionMenu: React.FC = ({ containerR
'hover:bg-[var(--interactive-hover)] disabled:opacity-60 disabled:cursor-not-allowed',
'transition-colors duration-150'
)}
- title="Save distilled insight to notes"
+ title={t('chat.textSelection.title.saveInsightToNotes')}
type="button"
>
{isAddingToNotes ? : }
- Add to notes
+ {t('chat.textSelection.actions.addToNotes')}
>
) : null}
diff --git a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx
index 1433520f..1afec54f 100644
--- a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx
+++ b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx
@@ -26,6 +26,7 @@ import type { ToolPopupContent, DiffViewMode } from './types';
import { DiffViewToggle } from './DiffViewToggle';
import { VirtualizedCodeBlock, type CodeLine } from './parts/VirtualizedCodeBlock';
import { JsonTreeView } from '@/components/ui/JsonTreeView';
+import { useI18n } from '@/lib/i18n';
interface ToolOutputDialogProps {
popup: ToolPopupContent;
@@ -302,6 +303,7 @@ const ImagePreviewDialog: React.FC<{
onOpenChange: (open: boolean) => void;
isMobile: boolean;
}> = ({ popup, onOpenChange, isMobile }) => {
+ const { t } = useI18n();
const gallery = React.useMemo(() => {
const baseImage = popup.image;
if (!baseImage) return [] as Array<{ url: string; mimeType?: string; filename?: string; size?: number }>;
@@ -434,7 +436,7 @@ const ImagePreviewDialog: React.FC<{
onMouseDown={(event) => event.stopPropagation()}
onClick={showPrevious}
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 h-10 w-10 flex items-center justify-center rounded-full bg-black/40 text-foreground/90 hover:bg-black/55 focus:outline-none focus:ring-2 focus:ring-primary/60"
- aria-label="Previous image"
+ aria-label={t('chat.toolOutputDialog.image.previousAria')}
>
@@ -443,7 +445,7 @@ const ImagePreviewDialog: React.FC<{
onMouseDown={(event) => event.stopPropagation()}
onClick={showNext}
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 h-10 w-10 flex items-center justify-center rounded-full bg-black/40 text-foreground/90 hover:bg-black/55 focus:outline-none focus:ring-2 focus:ring-primary/60"
- aria-label="Next image"
+ aria-label={t('chat.toolOutputDialog.image.nextAria')}
>
@@ -472,7 +474,7 @@ const ImagePreviewDialog: React.FC<{
type="button"
className="h-8 w-8 flex items-center justify-center rounded-lg text-muted-foreground/80 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-primary/60"
onClick={() => onOpenChange(false)}
- aria-label="Close image preview"
+ aria-label={t('chat.toolOutputDialog.image.closeAria')}
>
@@ -632,6 +634,7 @@ const MermaidPreviewDialog: React.FC<{
onOpenChange: (open: boolean) => void;
isMobile: boolean;
}> = ({ popup, onOpenChange, isMobile }) => {
+ const { t } = useI18n();
const [source, setSource] = React.useState(popup.mermaid?.source || '');
const [status, setStatus] = React.useState<'idle' | 'loading' | 'ready' | 'error'>(popup.mermaid?.source ? 'ready' : 'idle');
const [errorMessage, setErrorMessage] = React.useState('');
@@ -707,7 +710,7 @@ const MermaidPreviewDialog: React.FC<{
const target = popup.mermaid;
if (!target?.url) {
setStatus('error');
- setErrorMessage('Missing Mermaid source URL.');
+ setErrorMessage(t('chat.toolOutputDialog.mermaid.missingSource'));
return;
}
@@ -773,7 +776,7 @@ const MermaidPreviewDialog: React.FC<{
return;
}
setStatus('error');
- setErrorMessage(error instanceof Error ? error.message : 'Unable to load Mermaid diagram.');
+ setErrorMessage(error instanceof Error ? error.message : t('chat.toolOutputDialog.mermaid.loadFailed'));
});
}, [decodeDataUrl, normalizeFilePath, popup.mermaid]);
@@ -918,7 +921,7 @@ const MermaidPreviewDialog: React.FC<{
type="button"
className="h-8 w-8 flex items-center justify-center rounded-lg text-muted-foreground/80 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-primary/60"
onClick={() => onOpenChange(false)}
- aria-label="Close diagram preview"
+ aria-label={t('chat.toolOutputDialog.mermaid.closeAria')}
>
@@ -931,14 +934,14 @@ const MermaidPreviewDialog: React.FC<{
{status === 'loading' && (
- Loading diagram...
+ {t('chat.toolOutputDialog.mermaid.loading')}
)}
{status === 'error' && (
- {errorMessage || 'Unable to render Mermaid diagram.'}
+ {errorMessage || t('chat.toolOutputDialog.mermaid.renderFailed')}
- Retry
+ {t('chat.toolOutputDialog.mermaid.retry')}
)}
@@ -983,6 +986,7 @@ const MermaidPreviewDialog: React.FC<{
};
const ToolOutputDialog: React.FC = ({ popup, onOpenChange, syntaxTheme, isMobile }) => {
+ const { t } = useI18n();
const [diffViewMode, setDiffViewMode] = React.useState('unified');
const pierreThemeConfig = usePierreThemeConfig();
@@ -1112,7 +1116,13 @@ const ToolOutputDialog: React.FC = ({ popup, onOpenChange
if (tool === 'todowrite' || tool === 'todoread') {
return (
- renderTodoOutput(popup.content) || (
+ renderTodoOutput(popup.content, {
+ total: t('chat.todo.total'),
+ inProgress: t('chat.todo.inProgress'),
+ pending: t('chat.todo.pending'),
+ completed: t('chat.todo.completed'),
+ cancelled: t('chat.todo.cancelled'),
+ }) || (
= ({ popup, onOpenChange
) : (
- Command completed successfully
- No output was produced
+ {t('chat.toolOutputDialog.commandCompleted')}
+ {t('chat.toolOutputDialog.noOutputProduced')}
)}
diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx
index 67f5c659..c3b2857c 100644
--- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx
+++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx
@@ -41,6 +41,7 @@ import { getToolIcon } from './toolPresentation';
import { useDurationTickerNow } from './useDurationTicker';
import { resolveFallbackTaskSessionId } from './resolveFallbackTaskSessionId';
import { areRenderRelevantPartsEqual } from '../renderCompare';
+import { useI18n } from '@/lib/i18n';
type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record; input?: Record; output?: string; error?: string; time?: { start: number; end?: number } };
@@ -1060,6 +1061,7 @@ const TaskToolSummary: React.FC<{
animateTailText?: boolean;
isActive?: boolean;
}> = ({ entries, isExpanded, isMobile, output, sessionId, onShowPopup, input, animateTailText = true, isActive = false }) => {
+ const { t } = useI18n();
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const showToolFileIcons = useUIStore((state) => state.showToolFileIcons);
const displayEntries = entries;
@@ -1171,7 +1173,7 @@ const TaskToolSummary: React.FC<{
onClick={handleOpenSession}
>
- Open {agentType.charAt(0).toUpperCase() + agentType.slice(1)} subtask
+ {t('chat.toolPart.openSubtask', { type: agentType.charAt(0).toUpperCase() + agentType.slice(1) })}
)}
@@ -1192,7 +1194,7 @@ const TaskToolSummary: React.FC<{
) : (
)}
- Output
+ {t('chat.toolPart.output')}
{isOutputExpanded ? (
@@ -1409,6 +1411,7 @@ const ToolExpandedContent: React.FC = React.memo(({
currentDirectory,
onShowPopup,
}) => {
+ const { t } = useI18n();
const { pierreTheme, pierreThemeType } = usePierreThemeConfig();
const [diffViewMode, setDiffViewMode] = React.useState('unified');
const stateWithData = state as ToolStateWithMetadata;
@@ -1497,7 +1500,7 @@ const ToolExpandedContent: React.FC = React.memo(({
}}
>
- LSP errors
+ {t('chat.toolPart.lspErrors')}
@@ -1519,7 +1522,7 @@ const ToolExpandedContent: React.FC = React.memo(({
{diagnosticSection.remaining > 0 ? (
- +{diagnosticSection.remaining} more errors
+ {t('chat.toolPart.moreErrors', { count: diagnosticSection.remaining })}
) : null}
@@ -1549,7 +1552,7 @@ const ToolExpandedContent: React.FC = React.memo(({
if (state.status === 'error' && 'error' in state) {
return (
- Error:
+ {t('chat.toolPart.error')}
= React.memo(({
);
}
- return Awaiting response...;
+ return {t('chat.toolPart.awaitingResponse')};
}
if (part.tool === 'task' && hasStringOutput) {
@@ -1655,7 +1658,7 @@ const ToolExpandedContent: React.FC = React.memo(({
}
return renderScrollableBlock(
- No output produced,
+ {t('chat.toolPart.noOutputProduced')},
{ maxHeightClass: 'max-h-60' }
);
};
@@ -1714,7 +1717,7 @@ const ToolExpandedContent: React.FC = React.memo(({
{state.status === 'error' && 'error' in state && (
- Error:
+ {t('chat.toolPart.error')}
{
+export const renderTodoOutput = (
+ output: string,
+ labels: {
+ total: string;
+ inProgress: string;
+ pending: string;
+ completed: string;
+ cancelled: string;
+ },
+ options?: { unstyled?: boolean },
+) => {
try {
const todos = JSON.parse(output) as Todo[];
if (!Array.isArray(todos)) {
@@ -408,18 +418,18 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean
style={typography.tool.popup}
>
- Total: {todos.length}
+ {labels.total}: {todos.length}
{todosByStatus.in_progress.length > 0 && (
- In Progress: {todosByStatus.in_progress.length}
+ {labels.inProgress}: {todosByStatus.in_progress.length}
)}
{todosByStatus.pending.length > 0 && (
- Pending: {todosByStatus.pending.length}
+ {labels.pending}: {todosByStatus.pending.length}
)}
{todosByStatus.completed.length > 0 && (
- Completed: {todosByStatus.completed.length}
+ {labels.completed}: {todosByStatus.completed.length}
)}
{todosByStatus.cancelled.length > 0 && (
- Cancelled: {todosByStatus.cancelled.length}
+ {labels.cancelled}: {todosByStatus.cancelled.length}
)}
@@ -427,7 +437,7 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean
- In Progress
+ {labels.inProgress}
{todosByStatus.in_progress.map((todo, idx) => (
@@ -444,7 +454,7 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean
- Pending
+ {labels.pending}
{todosByStatus.pending.map((todo, idx) => (
@@ -461,7 +471,7 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean
- Completed
+ {labels.completed}
{todosByStatus.completed.map((todo, idx) => (
@@ -478,7 +488,7 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean
×
- Cancelled
+ {labels.cancelled}
{todosByStatus.cancelled.map((todo, idx) => (
diff --git a/packages/ui/src/components/comments/InlineCommentCard.tsx b/packages/ui/src/components/comments/InlineCommentCard.tsx
index 8936ec43..72b39351 100644
--- a/packages/ui/src/components/comments/InlineCommentCard.tsx
+++ b/packages/ui/src/components/comments/InlineCommentCard.tsx
@@ -11,6 +11,7 @@ import {
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
+import { useI18n } from '@/lib/i18n';
interface InlineCommentCardProps {
draft: InlineCommentDraft;
@@ -27,6 +28,7 @@ export function InlineCommentCard({
className,
maxWidth,
}: InlineCommentCardProps) {
+ const { t } = useI18n();
const themeContext = useOptionalThemeSystem();
const currentTheme = themeContext?.currentTheme;
const [isOpen, setIsOpen] = useState(false);
@@ -56,7 +58,7 @@ export function InlineCommentCard({
{draft.fileLabel}
•
- Lines {draft.startLine}-{draft.endLine}
+ {t('inlineComment.range.lines', { start: draft.startLine, end: draft.endLine })}
{draft.side && ({draft.side})}
@@ -75,12 +77,12 @@ export function InlineCommentCard({
{isOpen ? (
<>
- Show less
+ {t('inlineComment.actions.showLess')}
>
) : (
<>
- Show more
+ {t('inlineComment.actions.showMore')}
>
)}
@@ -106,11 +108,11 @@ export function InlineCommentCard({
- Edit comment
+ {t('inlineComment.actions.editComment')}
- Delete comment
+ {t('inlineComment.actions.deleteComment')}
diff --git a/packages/ui/src/components/comments/InlineCommentInput.tsx b/packages/ui/src/components/comments/InlineCommentInput.tsx
index c66e61c0..56477e22 100644
--- a/packages/ui/src/components/comments/InlineCommentInput.tsx
+++ b/packages/ui/src/components/comments/InlineCommentInput.tsx
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { useDeviceInfo } from '@/lib/device';
+import { useI18n } from '@/lib/i18n';
export interface InlineCommentInputProps {
initialText?: string;
@@ -26,6 +27,7 @@ export function InlineCommentInput({
className,
maxWidth,
}: InlineCommentInputProps) {
+ const { t } = useI18n();
const themeContext = useOptionalThemeSystem();
const currentTheme = themeContext?.currentTheme;
const { isMobile } = useDeviceInfo();
@@ -131,7 +133,11 @@ export function InlineCommentInput({
{fileLabel && {fileLabel}}
{fileLabel && lineRange && •}
- {displayRange && Lines {displayRange.start}-{displayRange.end}}
+ {displayRange && (
+
+ {t('inlineComment.range.lines', { start: displayRange.start, end: displayRange.end })}
+
+ )}
)}
@@ -141,7 +147,7 @@ export function InlineCommentInput({
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
- placeholder="Add a comment... (Cmd+Enter to save)"
+ placeholder={t('inlineComment.input.placeholder')}
outerClassName="rounded-[var(--radius-xl)] bg-[var(--surface-subtle)] ring-1 ring-inset ring-border/60 focus-within:ring-2 focus-within:ring-[var(--interactive-focus-ring)]"
className="min-h-[80px] px-3 py-2.5 text-sm resize-y"
/>
@@ -155,7 +161,7 @@ export function InlineCommentInput({
onTouchStart={(e) => e.stopPropagation()}
className="h-8 text-muted-foreground hover:text-foreground"
>
- Cancel
+ {t('inlineComment.actions.cancel')}
- {isEditing ? 'Save' : 'Comment'}
+ {isEditing ? t('inlineComment.actions.save') : t('inlineComment.actions.comment')}
diff --git a/packages/ui/src/components/comments/useInlineCommentController.ts b/packages/ui/src/components/comments/useInlineCommentController.ts
index db27e875..7b54d6de 100644
--- a/packages/ui/src/components/comments/useInlineCommentController.ts
+++ b/packages/ui/src/components/comments/useInlineCommentController.ts
@@ -2,6 +2,7 @@ import React from 'react';
import { toast } from '@/components/ui';
import { useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentSource } from '@/stores/useInlineCommentDraftStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
+import { useI18n } from '@/lib/i18n';
type LineRangeBase = {
start: number;
@@ -46,6 +47,7 @@ export const normalizeLineRange = (range: TRange):
export function useInlineCommentController(
options: UseInlineCommentControllerOptions
) {
+ const { t } = useI18n();
const { source, fileLabel, language, getCodeForRange, toStoreRange, fromDraftRange } = options;
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
@@ -100,7 +102,7 @@ export function useInlineCommentController(
if (!targetRange || !trimmedText || !fileLabel) return;
if (!sessionKey) {
- toast.error('Select a session to save comment');
+ toast.error(t('inlineComment.toast.selectSessionToSave'));
return;
}
@@ -133,7 +135,7 @@ export function useInlineCommentController(
}
reset();
- }, [addDraft, editingDraftId, fileLabel, getCodeForRange, language, reset, selection, sessionKey, source, toStoreRange, updateDraft]);
+ }, [addDraft, editingDraftId, fileLabel, getCodeForRange, language, reset, selection, sessionKey, source, t, toStoreRange, updateDraft]);
return {
sessionKey,
diff --git a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx
index 7109bf13..24901bef 100644
--- a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx
+++ b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx
@@ -37,6 +37,7 @@ import { cn } from '@/lib/utils';
import { toast } from '@/components/ui';
import { isTauriShell, isDesktopShell } from '@/lib/desktop';
import { useUIStore } from '@/stores/useUIStore';
+import { useI18n } from '@/lib/i18n';
import {
desktopHostProbe,
desktopHostsGet,
@@ -102,12 +103,17 @@ const statusDotClass = (status: HostProbeResult['status'] | null): string => {
return 'bg-muted-foreground/40';
};
-const statusLabel = (status: HostProbeResult['status'] | null): string => {
- if (status === 'ok') return 'Connected';
- if (status === 'auth') return 'Auth required';
- if (status === 'wrong-service') return 'Wrong service';
- if (status === 'unreachable') return 'Unreachable';
- return 'Unknown';
+const statusLabelKey = (status: HostProbeResult['status'] | null):
+ | 'desktopHostSwitcher.status.connected'
+ | 'desktopHostSwitcher.status.authRequired'
+ | 'desktopHostSwitcher.status.wrongService'
+ | 'desktopHostSwitcher.status.unreachable'
+ | 'desktopHostSwitcher.status.unknown' => {
+ if (status === 'ok') return 'desktopHostSwitcher.status.connected';
+ if (status === 'auth') return 'desktopHostSwitcher.status.authRequired';
+ if (status === 'wrong-service') return 'desktopHostSwitcher.status.wrongService';
+ if (status === 'unreachable') return 'desktopHostSwitcher.status.unreachable';
+ return 'desktopHostSwitcher.status.unknown';
};
const statusIcon = (status: HostProbeResult['status'] | null) => {
@@ -120,34 +126,47 @@ const statusIcon = (status: HostProbeResult['status'] | null) => {
const sleep = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
-const sshPhaseLabel = (phase: DesktopSshInstanceStatus['phase'] | undefined): string => {
+const sshPhaseLabelKey = (phase: DesktopSshInstanceStatus['phase'] | undefined):
+ | 'desktopHostSwitcher.sshPhase.ready'
+ | 'desktopHostSwitcher.sshPhase.error'
+ | 'desktopHostSwitcher.sshPhase.reconnecting'
+ | 'desktopHostSwitcher.sshPhase.resolvingConfig'
+ | 'desktopHostSwitcher.sshPhase.checkingAuth'
+ | 'desktopHostSwitcher.sshPhase.connectingSsh'
+ | 'desktopHostSwitcher.sshPhase.probingRemote'
+ | 'desktopHostSwitcher.sshPhase.installing'
+ | 'desktopHostSwitcher.sshPhase.updating'
+ | 'desktopHostSwitcher.sshPhase.detectingServer'
+ | 'desktopHostSwitcher.sshPhase.startingServer'
+ | 'desktopHostSwitcher.sshPhase.forwardingPorts'
+ | 'desktopHostSwitcher.sshPhase.idle' => {
switch (phase) {
case 'ready':
- return 'Ready';
+ return 'desktopHostSwitcher.sshPhase.ready';
case 'error':
- return 'Error';
+ return 'desktopHostSwitcher.sshPhase.error';
case 'degraded':
- return 'Reconnecting';
+ return 'desktopHostSwitcher.sshPhase.reconnecting';
case 'config_resolved':
- return 'Resolving config';
+ return 'desktopHostSwitcher.sshPhase.resolvingConfig';
case 'auth_check':
- return 'Checking auth';
+ return 'desktopHostSwitcher.sshPhase.checkingAuth';
case 'master_connecting':
- return 'Connecting SSH';
+ return 'desktopHostSwitcher.sshPhase.connectingSsh';
case 'remote_probe':
- return 'Probing remote';
+ return 'desktopHostSwitcher.sshPhase.probingRemote';
case 'installing':
- return 'Installing';
+ return 'desktopHostSwitcher.sshPhase.installing';
case 'updating':
- return 'Updating';
+ return 'desktopHostSwitcher.sshPhase.updating';
case 'server_detecting':
- return 'Detecting server';
+ return 'desktopHostSwitcher.sshPhase.detectingServer';
case 'server_starting':
- return 'Starting server';
+ return 'desktopHostSwitcher.sshPhase.startingServer';
case 'forwarding':
- return 'Forwarding ports';
+ return 'desktopHostSwitcher.sshPhase.forwardingPorts';
default:
- return 'Idle';
+ return 'desktopHostSwitcher.sshPhase.idle';
}
};
@@ -246,6 +265,7 @@ export function DesktopHostSwitcherDialog({
embedded = false,
onHostSwitched,
}: DesktopHostSwitcherDialogProps) {
+ const { t } = useI18n();
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
@@ -296,8 +316,8 @@ export function DesktopHostSwitcherDialog({
const current = React.useMemo(() => resolveCurrentHost(allHosts), [allHosts]);
const currentDefaultLabel = React.useMemo(() => {
const id = defaultHostId || LOCAL_HOST_ID;
- return allHosts.find((h) => h.id === id)?.label || 'Local';
- }, [allHosts, defaultHostId]);
+ return allHosts.find((h) => h.id === id)?.label || t('desktopHostSwitcher.instance.local');
+ }, [allHosts, defaultHostId, t]);
const persist = React.useCallback(async (nextHosts: DesktopHost[], nextDefaultHostId: string | null) => {
if (!isTauriShell()) return;
@@ -309,11 +329,11 @@ export function DesktopHostSwitcherDialog({
setConfigHosts(remote);
setDefaultHostId(nextDefaultHostId);
} catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to save');
+ setError(err instanceof Error ? err.message : t('desktopHostSwitcher.error.failedToSave'));
} finally {
setIsSaving(false);
}
- }, []);
+ }, [t]);
const openRemoteInstancesSettings = React.useCallback(() => {
setSettingsPage('remote-instances');
@@ -340,7 +360,7 @@ export function DesktopHostSwitcherDialog({
setSshHostIds(nextSshHostIds);
setSshStatusesById(sshStatusMap);
} catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to load');
+ setError(err instanceof Error ? err.message : t('desktopHostSwitcher.error.failedToLoad'));
setConfigHosts([]);
setDefaultHostId(null);
setSshHostIds({});
@@ -348,7 +368,7 @@ export function DesktopHostSwitcherDialog({
} finally {
setIsLoading(false);
}
- }, []);
+ }, [t]);
const probeAll = React.useCallback(async (hosts: DesktopHost[]) => {
if (!isTauriShell()) return;
@@ -500,7 +520,7 @@ export function DesktopHostSwitcherDialog({
...prev,
error: message,
}));
- toast.error(`SSH instance "${redactSensitiveUrl(host.label)}" failed to connect`, {
+ toast.error(t('desktopHostSwitcher.toast.sshFailedToConnect', { host: redactSensitiveUrl(host.label) }), {
description: message,
});
return;
@@ -520,7 +540,7 @@ export function DesktopHostSwitcherDialog({
}));
if (probe.status === 'unreachable' || probe.status === 'wrong-service') {
- toast.error(`Instance "${redactSensitiveUrl(host.label)}" is unreachable`);
+ toast.error(t('desktopHostSwitcher.toast.instanceUnreachable', { host: redactSensitiveUrl(host.label) }));
setSwitchingHostId(null);
return;
}
@@ -534,7 +554,7 @@ export function DesktopHostSwitcherDialog({
} catch {
window.location.href = target;
}
- }, [onHostSwitched, sshHostIds, sshStatusesById]);
+ }, [onHostSwitched, sshHostIds, sshStatusesById, t]);
const beginEdit = React.useCallback((host: DesktopHost) => {
setEditingId(host.id);
@@ -562,7 +582,7 @@ export function DesktopHostSwitcherDialog({
const url = normalizeHostUrl(editUrl);
if (!url) {
- setError('Invalid URL (must be http/https)');
+ setError(t('desktopHostSwitcher.error.invalidUrl'));
return;
}
@@ -570,12 +590,12 @@ export function DesktopHostSwitcherDialog({
const nextHosts = configHosts.map((h) => (h.id === editingId ? { ...h, label, url } : h));
await persist(nextHosts, defaultHostId);
cancelEdit();
- }, [cancelEdit, configHosts, defaultHostId, editLabel, editUrl, editingId, persist]);
+ }, [cancelEdit, configHosts, defaultHostId, editLabel, editUrl, editingId, persist, t]);
const addHost = React.useCallback(async () => {
const url = normalizeHostUrl(newUrl);
if (!url) {
- setError('Invalid URL (must be http/https)');
+ setError(t('desktopHostSwitcher.error.invalidUrl'));
return;
}
const label = (newLabel || redactSensitiveUrl(url)).trim();
@@ -588,7 +608,7 @@ export function DesktopHostSwitcherDialog({
if (embedded) {
setIsAddFormOpen(false);
}
- }, [configHosts, defaultHostId, embedded, newLabel, newUrl, persist]);
+ }, [configHosts, defaultHostId, embedded, newLabel, newUrl, persist, t]);
const deleteHost = React.useCallback(async (id: string) => {
if (id === LOCAL_HOST_ID) return;
@@ -607,11 +627,11 @@ export function DesktopHostSwitcherDialog({
if (!origin) return;
const target = toNavigationUrl(origin);
desktopOpenNewWindowAtUrl(target).catch((err: unknown) => {
- toast.error('Failed to open new window', {
+ toast.error(t('desktopHostSwitcher.error.failedToOpenNewWindow'), {
description: err instanceof Error ? err.message : String(err),
});
});
- }, []);
+ }, [t]);
const switchToLocal = React.useCallback(() => {
sshSwitchTokenRef.current += 1;
@@ -669,19 +689,19 @@ export function DesktopHostSwitcherDialog({
}));
});
if (readyStatus.phase === 'ready') {
- toast.success(`SSH instance "${redactSensitiveUrl(host.label)}" connected`);
+ toast.success(t('desktopHostSwitcher.toast.sshConnected', { host: redactSensitiveUrl(host.label) }));
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message !== SSH_CONNECT_CANCELLED_ERROR) {
- toast.error(`SSH instance "${redactSensitiveUrl(host.label)}" failed to connect`, {
+ toast.error(t('desktopHostSwitcher.toast.sshFailedToConnect', { host: redactSensitiveUrl(host.label) }), {
description: message,
});
}
} finally {
setSwitchingHostId(null);
}
- }, []);
+ }, [t]);
if (!isDesktopShell()) {
return null;
@@ -695,10 +715,10 @@ export function DesktopHostSwitcherDialog({
- Current
+ {t('desktopHostSwitcher.header.current')}
{redactSensitiveUrl(current.label)}
•
- Default
+ {t('desktopHostSwitcher.header.default')}
{redactSensitiveUrl(currentDefaultLabel)}
void probeAll(allHosts)}
disabled={!tauriAvailable || isLoading || isProbing}
- aria-label="Refresh instances"
+ aria-label={t('desktopHostSwitcher.actions.refreshInstancesAria')}
>
@@ -720,10 +740,10 @@ export function DesktopHostSwitcherDialog({
- Instance
+ {t('desktopHostSwitcher.title')}
- Switch between Local and remote OpenChamber servers
+ {t('desktopHostSwitcher.description')}
)}
@@ -731,9 +751,9 @@ export function DesktopHostSwitcherDialog({
{!embedded && (
- Current:
+ {t('desktopHostSwitcher.header.currentColon')}
{redactSensitiveUrl(current.label)}
- Current default:
+ {t('desktopHostSwitcher.header.currentDefaultColon')}
{redactSensitiveUrl(currentDefaultLabel)}
@@ -745,7 +765,7 @@ export function DesktopHostSwitcherDialog({
disabled={!tauriAvailable || isLoading || isProbing}
>
- Refresh
+ {t('desktopHostSwitcher.actions.refresh')}
@@ -753,10 +773,10 @@ export function DesktopHostSwitcherDialog({
{tauriAvailable && (
- Need SSH instances?
Manage them in Settings.
+ {t('desktopHostSwitcher.ssh.needInstancesHint')}
- Remote SSH
+ {t('desktopHostSwitcher.actions.remoteSsh')}
)}
@@ -764,7 +784,7 @@ export function DesktopHostSwitcherDialog({
{!tauriAvailable && (
- Instance switcher is limited on this page. Use Local to recover.
+ {t('desktopHostSwitcher.state.limitedOnPage')}
)}
@@ -772,7 +792,7 @@ export function DesktopHostSwitcherDialog({
{isLoading ? (
- Loading…
+ {t('desktopHostSwitcher.state.loading')}
) : (
allHosts.map((host) => {
const isLocal = host.id === LOCAL_HOST_ID;
@@ -784,7 +804,9 @@ export function DesktopHostSwitcherDialog({
const statusKind = isSsh ? sshPhaseToHostStatus(sshStatus?.phase) : (status?.status ?? null);
const isEditing = editingId === host.id;
const effectiveUrl = isLocal ? getLocalOrigin() : (normalizeHostUrl(host.url) || host.url);
- const displayLabel = redactSensitiveUrl(host.label);
+ const displayLabel = host.id === LOCAL_HOST_ID
+ ? t('desktopHostSwitcher.instance.local')
+ : redactSensitiveUrl(host.label);
const displayUrl = redactSensitiveUrl(effectiveUrl);
return (
@@ -803,7 +825,7 @@ export function DesktopHostSwitcherDialog({
)}
onClick={() => void handleSwitch(host)}
disabled={switchingHostId === host.id}
- aria-label={`Switch to ${displayLabel}`}
+ aria-label={t('desktopHostSwitcher.actions.switchToAria', { instance: displayLabel })}
>
@@ -817,13 +839,15 @@ export function DesktopHostSwitcherDialog({
)}
{isActive && (
- Current
+ {t('desktopHostSwitcher.header.current')}
)}
{statusIcon(statusKind)}
- {isSsh ? sshPhaseLabel(sshStatus?.phase) : statusLabel(status?.status ?? null)}
- {!isSsh && status?.status === 'ok' && typeof status.latencyMs === 'number' ? ` · ${Math.max(0, Math.round(status.latencyMs))}ms ping` : ''}
+ {isSsh ? t(sshPhaseLabelKey(sshStatus?.phase)) : t(statusLabelKey(status?.status ?? null))}
+ {!isSsh && status?.status === 'ok' && typeof status.latencyMs === 'number'
+ ? t('desktopHostSwitcher.status.ping', { ms: Math.max(0, Math.round(status.latencyMs)) })
+ : ''}
@@ -840,7 +864,7 @@ export function DesktopHostSwitcherDialog({
e.stopPropagation()}
>
@@ -856,7 +880,7 @@ export function DesktopHostSwitcherDialog({
disabled={isSaving}
>
- Edit
+ {t('desktopHostSwitcher.actions.edit')}
{
@@ -867,7 +891,7 @@ export function DesktopHostSwitcherDialog({
disabled={isSaving}
>
- Delete
+ {t('desktopHostSwitcher.actions.delete')}
@@ -894,7 +918,7 @@ export function DesktopHostSwitcherDialog({
}}
>
{switchingHostId === host.id ? : }
- Connect
+ {t('desktopHostSwitcher.actions.connect')}
) : (
void setDefault(host.id)}
- aria-label={isDefault ? 'Default instance' : 'Set as default'}
+ aria-label={isDefault ? t('desktopHostSwitcher.actions.defaultInstanceAria') : t('desktopHostSwitcher.actions.setAsDefaultAria')}
disabled={isSaving || (!isDefault && (statusKind === 'unreachable' || statusKind === 'wrong-service'))}
>
{isDefault ? : }
- {isDefault ? 'Default' : 'Set as default'}
+ {isDefault ? t('desktopHostSwitcher.header.default') : t('desktopHostSwitcher.actions.setAsDefault')}
@@ -941,13 +965,15 @@ export function DesktopHostSwitcherDialog({
openInNewWindow(host);
}}
disabled={statusKind === 'unreachable' || statusKind === 'wrong-service'}
- aria-label="Open in new window"
+ aria-label={t('desktopHostSwitcher.actions.openInNewWindowAria')}
>
- {(statusKind === 'unreachable' || statusKind === 'wrong-service') ? 'Instance unreachable' : 'Open in new window'}
+ {(statusKind === 'unreachable' || statusKind === 'wrong-service')
+ ? t('desktopHostSwitcher.state.instanceUnreachable')
+ : t('desktopHostSwitcher.actions.openInNewWindow')}
@@ -961,14 +987,14 @@ export function DesktopHostSwitcherDialog({
{tauriAvailable && editingId && editingId !== LOCAL_HOST_ID && (
- Edit instance
+ {t('desktopHostSwitcher.edit.title')}
- Cancel
+ {t('desktopHostSwitcher.actions.cancel')}
void commitEdit()} disabled={isSaving}>
{isSaving ? : null}
- Save
+ {t('desktopHostSwitcher.actions.save')}
@@ -977,14 +1003,14 @@ export function DesktopHostSwitcherDialog({
value={editLabel}
onChange={(e) => setEditLabel(e.target.value)}
onKeyDown={stopDropdownTypeahead}
- placeholder="Label"
+ placeholder={t('desktopHostSwitcher.field.labelPlaceholder')}
disabled={isSaving}
/>
setEditUrl(e.target.value)}
onKeyDown={stopDropdownTypeahead}
- placeholder="https://host:port"
+ placeholder={t('desktopHostSwitcher.field.urlPlaceholder')}
disabled={isSaving}
/>
@@ -1000,7 +1026,7 @@ export function DesktopHostSwitcherDialog({
disabled={!tauriAvailable || isSaving}
>
- Add instance
+ {t('desktopHostSwitcher.actions.addInstance')}
) : (
@@ -1011,7 +1037,7 @@ export function DesktopHostSwitcherDialog({
: 'rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-2.5'
)}>
- Add instance
+ {t('desktopHostSwitcher.add.title')}
{embedded && (
setIsAddFormOpen(false)}
disabled={isSaving}
>
- Cancel
+ {t('desktopHostSwitcher.actions.cancel')}
)}
{isSaving ? : null}
- Add
+ {t('desktopHostSwitcher.actions.add')}
@@ -1040,14 +1066,14 @@ export function DesktopHostSwitcherDialog({
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
onKeyDown={stopDropdownTypeahead}
- placeholder="Label (optional)"
+ placeholder={t('desktopHostSwitcher.field.labelOptionalPlaceholder')}
disabled={!tauriAvailable || isSaving}
/>
setNewUrl(e.target.value)}
onKeyDown={stopDropdownTypeahead}
- placeholder="https://host:port"
+ placeholder={t('desktopHostSwitcher.field.urlPlaceholder')}
disabled={!tauriAvailable || isSaving}
/>
@@ -1079,12 +1105,12 @@ export function DesktopHostSwitcherDialog({
- Connecting to {sshSwitchModal.hostLabel || 'SSH instance'}
+ {t('desktopHostSwitcher.ssh.connectingTo', { host: sshSwitchModal.hostLabel || t('desktopHostSwitcher.ssh.instanceFallback') })}
{sshSwitchModal.error
? sshSwitchModal.error
- : sshSwitchModal.detail || sshPhaseLabel(sshSwitchModal.phase)}
+ : sshSwitchModal.detail || t(sshPhaseLabelKey(sshSwitchModal.phase))}
{sshSwitchModal.error ? (
@@ -1095,7 +1121,7 @@ export function DesktopHostSwitcherDialog({
variant="outline"
onClick={switchToLocal}
>
- Switch to Local
+ {t('desktopHostSwitcher.actions.switchToLocal')}
- Retry
+ {t('desktopHostSwitcher.actions.retry')}
) : null}
@@ -1139,6 +1165,7 @@ type DesktopHostSwitcherButtonProps = {
};
export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHostSwitcherButtonProps) {
+ const { t } = useI18n();
const [open, setOpen] = React.useState(false);
const [label, setLabel] = React.useState('Local');
const [status, setStatus] = React.useState(null);
@@ -1251,7 +1278,7 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
}
if (cancelled) return;
- setLabel(redactSensitiveUrl(current.label || 'Instance'));
+ setLabel(redactSensitiveUrl(current.label || t('desktopHostSwitcher.instance.fallback')));
const normalized = normalizeHostUrl(current.url);
if (!normalized) {
setStatus(null);
@@ -1262,7 +1289,7 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
setStatus(res.status);
} catch {
if (!cancelled) {
- setLabel('Instance');
+ setLabel(t('desktopHostSwitcher.instance.fallback'));
setStatus(null);
}
}
@@ -1290,13 +1317,13 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
const fallbackLabel = typeof window !== 'undefined' && window.location.hostname
? window.location.hostname
- : 'Instance';
+ : t('desktopHostSwitcher.instance.fallback');
const effectiveLabel = isCurrentlyLocal
- ? 'Local'
- : label === 'Local'
- ? fallbackLabel
- : label;
+ ? t('desktopHostSwitcher.instance.local')
+ : label === 'Local'
+ ? fallbackLabel
+ : label;
const safeEffectiveLabel = redactSensitiveUrl(effectiveLabel);
return (
@@ -1306,7 +1333,7 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
setOpen(true)}
- aria-label="Switch instance"
+ aria-label={t('desktopHostSwitcher.actions.switchInstanceAria')}
data-oc-host-switcher
className={cn(headerIconButtonClass, 'relative w-auto px-3')}
>
@@ -1319,12 +1346,12 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
'pointer-events-none absolute top-1.5 right-1.5 h-1.5 w-1.5 rounded-full',
statusDotClass(status)
)}
- aria-label="Instance status"
+ aria-label={t('desktopHostSwitcher.statusAria')}
/>
- Instance
+ {t('desktopHostSwitcher.title')}
@@ -1347,11 +1374,11 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
>
- Default SSH instance unavailable
+ {t('desktopHostSwitcher.startup.title')}
{startupSshModal.connecting
- ? `Connecting to ${startupSshModal.hostLabel || 'SSH instance'}...`
- : startupSshModal.error || 'Failed to connect the default SSH instance.'}
+ ? t('desktopHostSwitcher.startup.connectingTo', { host: startupSshModal.hostLabel || t('desktopHostSwitcher.ssh.instanceFallback') })
+ : startupSshModal.error || t('desktopHostSwitcher.startup.failed')}
@@ -1362,7 +1389,7 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
onClick={() => void switchStartupToLocal()}
disabled={startupSshModal.connecting}
>
- Switch to Local
+ {t('desktopHostSwitcher.actions.switchToLocal')}
{startupSshModal.connecting ? : null}
- Retry
+ {t('desktopHostSwitcher.actions.retry')}
@@ -1382,6 +1409,7 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
export function DesktopHostSwitcherInline() {
const [open, setOpen] = React.useState(false);
+ const { t } = useI18n();
if (!isDesktopShell()) {
return null;
@@ -1398,7 +1426,7 @@ export function DesktopHostSwitcherInline() {
onClick={() => setOpen(true)}
>
- Switch instance
+ {t('desktopHostSwitcher.actions.switchInstance')}
>
diff --git a/packages/ui/src/components/desktop/OpenInAppButton.tsx b/packages/ui/src/components/desktop/OpenInAppButton.tsx
index 8be58585..2dd8fda4 100644
--- a/packages/ui/src/components/desktop/OpenInAppButton.tsx
+++ b/packages/ui/src/components/desktop/OpenInAppButton.tsx
@@ -13,6 +13,7 @@ import { isDesktopLocalOriginActive, isTauriShell, openDesktopPath, openDesktopP
import { DEFAULT_OPEN_IN_APP_ID, OPEN_IN_APPS } from '@/lib/openInApps';
import { useOpenInAppsStore, type OpenInAppOption } from '@/stores/useOpenInAppsStore';
import { RiArrowDownSLine, RiCheckLine, RiFileCopyLine, RiRefreshLine } from '@remixicon/react';
+import { useI18n } from '@/lib/i18n';
const FINDER_DEFAULT_ICON_DATA_URL = 'data:image/png;base64,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';
const TERMINAL_DEFAULT_ICON_DATA_URL = 'data:image/png;base64,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';
@@ -73,6 +74,7 @@ type OpenInAppButtonProps = {
};
export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps) => {
+ const { t } = useI18n();
const selectedAppId = useOpenInAppsStore((state) => state.selectedAppId);
const availableApps = useOpenInAppsStore((state) => state.availableApps);
const isCacheStale = useOpenInAppsStore((state) => state.isCacheStale);
@@ -124,7 +126,7 @@ export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps)
if (!result.ok) {
return;
}
- toast.success('Path copied to clipboard');
+ toast.success(t('openInApp.toast.pathCopied'));
};
return (
@@ -143,7 +145,7 @@ export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps)
'inline-flex h-full items-center gap-2 px-3 typography-ui-label font-medium',
'text-foreground hover:bg-interactive-hover transition-colors'
)}
- aria-label={`Open in ${selectedApp.label}`}
+ aria-label={t('openInApp.actions.openInAria', { app: selectedApp.label })}
>
- Open
+ {t('openInApp.actions.open')}
@@ -163,7 +165,7 @@ export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps)
'border-l border-[var(--interactive-border)] text-muted-foreground',
'hover:bg-interactive-hover hover:text-foreground transition-colors'
)}
- aria-label="Choose app to open"
+ aria-label={t('openInApp.actions.chooseAppAria')}
>
@@ -175,7 +177,7 @@ export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps)
>
void handleCopyPath()}>
- Copy Path
+ {t('openInApp.actions.copyPath')}
{availableApps.map((app) => {
@@ -204,7 +206,7 @@ export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps)
onClick={() => void loadInstalledApps(true)}
>
- Refresh Apps
+ {t('openInApp.actions.refreshApps')}
) : null}
diff --git a/packages/ui/src/components/layout/BottomTerminalDock.tsx b/packages/ui/src/components/layout/BottomTerminalDock.tsx
index eae906f4..bdf42fb7 100644
--- a/packages/ui/src/components/layout/BottomTerminalDock.tsx
+++ b/packages/ui/src/components/layout/BottomTerminalDock.tsx
@@ -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 = ({ 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 = ({ 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 = ({ 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 ? : }
@@ -172,8 +174,8 @@ export const BottomTerminalDock: React.FC = ({ 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')}
>
diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx
index 49e75327..cb90e032 100644
--- a/packages/ui/src/components/layout/ContextPanel.tsx
+++ b/packages/ui/src/components/layout/ContextPanel.tsx
@@ -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['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'
?
@@ -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 ? : }
@@ -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')}
>
@@ -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'
diff --git a/packages/ui/src/components/layout/ContextSidebarTab.tsx b/packages/ui/src/components/layout/ContextSidebarTab.tsx
index 235f2c26..f0613d1d 100644
--- a/packages/ui/src/components/layout/ContextSidebarTab.tsx
+++ b/packages/ui/src/components/layout/ContextSidebarTab.tsx
@@ -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>({});
@@ -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 (
-
- Open a session to inspect context.
+
+ {t('contextSidebar.empty.openSession')}
);
}
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 ── */}
- Context
+ {t('contextSidebar.section.context')}
{formatNumber(viewModel.tokenBreakdown.total)}
{viewModel.contextLimit ? ` / ${formatNumber(viewModel.contextLimit)}` : ''}
@@ -442,17 +443,17 @@ export const ContextPanelContent: React.FC = () => {
)}
- {viewModel.usagePercent.toFixed(1)}% used
+ {t('contextSidebar.context.percentUsed', { percent: viewModel.usagePercent.toFixed(1) })}
{/* ── Stat grid ── */}
{([
- { 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) => (
{item.label}
@@ -463,14 +464,14 @@ export const ContextPanelContent: React.FC = () => {
{/* ── Last turn tokens ── */}
- Last Assistant Message
+ {t('contextSidebar.section.lastAssistantMessage')}
{([
- { 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) => (
{item.label}
@@ -513,7 +514,7 @@ export const ContextPanelContent: React.FC = () => {
{/* ── Raw messages ── */}
- Raw Messages
+ {t('contextSidebar.section.rawMessages')}
{[...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 ? : }
diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx
index 40b16e53..057c5383 100644
--- a/packages/ui/src/components/layout/Header.tsx
+++ b/packages/ui/src/components/layout/Header.tsx
@@ -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 ? (
- GitHub Accounts
+ {t('header.github.accountsTitle')}
{githubAccounts.map((account) => {
@@ -184,7 +186,7 @@ const DesktopGitHubControl = React.memo(function DesktopGitHubControl({
{accountUser?.avatarUrl ? (
{githubAvatarUrl ? (
- {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'),
+ })}
@@ -365,7 +377,7 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
- Rate limits
+ {t('header.services.rateLimits')}
{formatTime(quotaLastUpdated)}
@@ -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')}
>
@@ -398,7 +410,7 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
{!hasRateLimits ? (
- No rate limits available.
+ {t('header.services.noRateLimits')}
) : null}
@@ -414,7 +426,7 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
{group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? (
- {group.error ?? 'No rate limits reported.'}
+ {group.error ?? t('header.services.noRateLimitsReported')}
) : (
@@ -617,6 +629,7 @@ export const Header: React.FC = ({
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 = ({
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 = ({
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 = ({
// 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 = ({
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 = ({
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 = ({
const mobileServicesTabItems = React.useMemo(() => {
return [
- { id: 'usage', label: 'Usage', icon: },
+ { id: 'usage', label: t('layout.services.usage'), icon: },
{ id: 'mcp', label: 'MCP', icon: },
];
- }, []);
+ }, [t]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -1633,17 +1646,17 @@ export const Header: React.FC = ({
{showPlanTab && (
-
+
- Plan ({shortcutLabel('toggle_context_plan')})
+ {t('header.actions.planWithShortcut', { shortcut: shortcutLabel('toggle_context_plan') })}
)}
@@ -1674,14 +1687,14 @@ export const Header: React.FC = ({
shortcutLabel={shortcutLabel}
/>
@@ -1709,12 +1722,12 @@ export const Header: React.FC = ({
)}
style={webWindowControlsOverlayStyle}
role="tablist"
- aria-label="Main navigation"
+ aria-label={t('header.navigation.mainAria')}
>
= ({
@@ -1734,7 +1747,7 @@ export const Header: React.FC = ({
- New session ({shortcutLabel('new_chat')})
+ {t('header.actions.newSessionWithShortcut', { shortcut: shortcutLabel('new_chat') })}
) : null}
@@ -1827,7 +1840,7 @@ export const Header: React.FC = ({
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')}
>
@@ -1836,7 +1849,7 @@ export const Header: React.FC = ({
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')}
>
@@ -1845,14 +1858,14 @@ export const Header: React.FC = ({
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')}
>
)}
{isSessionSwitcherOpen && (
- Sessions
+ {t('header.sessions.title')}
)}
@@ -1865,7 +1878,7 @@ export const Header: React.FC = ({
{tabs.map((tab) => {
const isActive = activeMainTab === tab.id;
@@ -1904,7 +1917,7 @@ export const Header: React.FC = ({
{tab.showDot && (
)}
@@ -1946,7 +1959,7 @@ export const Header: React.FC = ({
@@ -1954,7 +1967,7 @@ export const Header: React.FC = ({
- Services
+ {t('header.services.title')}
= ({
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')}
>
@@ -2004,7 +2017,7 @@ export const Header: React.FC = ({
- Rate limits
+ {t('header.services.rateLimits')}
{formatTime(quotaLastUpdated)}
@@ -2021,7 +2034,7 @@ export const Header: React.FC = ({
: 'text-muted-foreground hover:text-foreground'
)}
>
- Used
+ {t('header.services.used')}
·
= ({
: 'text-muted-foreground hover:text-foreground'
)}
>
- Remaining
+ {t('header.services.remaining')}
= ({
)}
onClick={handleUsageRefresh}
disabled={isQuotaLoading || isUsageRefreshSpinning}
- aria-label="Refresh rate limits"
+ aria-label={t('header.services.refreshRateLimitsAria')}
>
@@ -2056,7 +2069,7 @@ export const Header: React.FC = ({
{!hasRateLimits && (
- No rate limits available.
+ {t('header.services.noRateLimits')}
)}
@@ -2077,7 +2090,7 @@ export const Header: React.FC = ({
{group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? (
- {group.error ?? 'No rate limits reported.'}
+ {group.error ?? t('header.services.noRateLimitsReported')}
) : (
diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx
index b5887269..cfebc4cb 100644
--- a/packages/ui/src/components/layout/MainLayout.tsx
+++ b/packages/ui/src/components/layout/MainLayout.tsx
@@ -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) */}
diff --git a/packages/ui/src/components/layout/ProjectActionsButton.tsx b/packages/ui/src/components/layout/ProjectActionsButton.tsx
index 0d7286a0..eb956a55 100644
--- a/packages/ui/src/components/layout/ProjectActionsButton.tsx
+++ b/packages/ui/src/components/layout/ProjectActionsButton.tsx
@@ -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}
>
@@ -666,7 +668,7 @@ export const ProjectActionsButton = ({
onClick={openProjectActionsSettings}
>
- Add action
+ {t('projectActions.actions.addAction')}
);
}
@@ -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
?
@@ -709,7 +716,7 @@ export const ProjectActionsButton = ({
- Add new action
+ {t('projectActions.actions.addNewAction')}
{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 })}
>
{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')}
>
@@ -791,7 +800,7 @@ export const ProjectActionsButton = ({
- Add new action
+ {t('projectActions.actions.addNewAction')}
{actions.map((entry) => {
diff --git a/packages/ui/src/components/layout/ProjectEditDialog.tsx b/packages/ui/src/components/layout/ProjectEditDialog.tsx
index 7613636d..511d9fe6 100644
--- a/packages/ui/src/components/layout/ProjectEditDialog.tsx
+++ b/packages/ui/src/components/layout/ProjectEditDialog.tsx
@@ -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 = ({
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 = ({
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 = ({
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 = ({
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 (