diff --git a/web/src/App.tsx b/web/src/App.tsx index dfdd9903..9f835120 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -14,6 +14,7 @@ import { queryKeys } from '@/lib/query-keys' import { AppContextProvider } from '@/lib/app-context' import { fetchLatestMessages } from '@/lib/message-window-store' import { useAppGoBack } from '@/hooks/useAppGoBack' +import { useTranslation } from '@/lib/use-translation' import { LoginPrompt } from '@/components/LoginPrompt' import { InstallPrompt } from '@/components/InstallPrompt' import { OfflineBanner } from '@/components/OfflineBanner' @@ -34,6 +35,7 @@ export function App() { } function AppInner() { + const { t } = useTranslation() const { serverUrl, baseUrl, setServerUrl, clearServerUrl } = useServerUrl() const { authSource, isLoading: isAuthSourceLoading, setAccessToken } = useAuthSource(baseUrl) const { token, api, isLoading: isAuthLoading, error: authError, needsBinding, bind } = useAuth(authSource, baseUrl) @@ -223,7 +225,7 @@ function AppInner() { if (isAuthSourceLoading) { return (
- +
) } @@ -259,7 +261,7 @@ function AppInner() { if (isAuthLoading || (authSource && !token && !authError)) { return (
- +
) } @@ -275,7 +277,7 @@ function AppInner() { serverUrl={serverUrl} setServerUrl={setServerUrl} clearServerUrl={clearServerUrl} - error={authError ?? 'Authentication failed'} + error={authError ?? t('login.error.authFailed')} /> ) } @@ -283,9 +285,9 @@ function AppInner() { // Telegram auth failed return (
-
HAPI
+
{t('login.title')}
- {authError ?? 'Not authorized'} + {authError ?? t('login.error.authFailed')}
Open this page from Telegram using the bot's "Open App" button (not "Open in browser"). diff --git a/web/src/components/AssistantChat/ComposerButtons.tsx b/web/src/components/AssistantChat/ComposerButtons.tsx index 32f10c24..7d2a084f 100644 --- a/web/src/components/AssistantChat/ComposerButtons.tsx +++ b/web/src/components/AssistantChat/ComposerButtons.tsx @@ -1,4 +1,5 @@ import { ComposerPrimitive } from '@assistant-ui/react' +import { useTranslation } from '@/lib/use-translation' function SettingsIcon() { return ( @@ -126,14 +127,16 @@ export function ComposerButtons(props: { isSwitching: boolean onSwitch: () => void }) { + const { t } = useTranslation() + return (
{props.showSettingsButton ? ( ) } function MessageSkeleton() { + const { t } = useTranslation() const rows = [ { align: 'end', width: 'w-2/3', height: 'h-10' }, { align: 'start', width: 'w-3/4', height: 'h-12' }, @@ -34,7 +37,7 @@ function MessageSkeleton() { return (
- Loading messages… + {t('misc.loadingMessages')}
{rows.map((row, index) => (
@@ -72,6 +75,7 @@ export function HappyThread(props: { messagesVersion: number forceScrollToken: number }) { + const { t } = useTranslation() const viewportRef = useRef(null) const topSentinelRef = useRef(null) const loadLockRef = useRef(false) @@ -300,12 +304,12 @@ export function HappyThread(props: { {props.isLoadingMoreMessages ? ( <> - Loading… + {t('misc.loading')} ) : ( <> - Load older + {t('misc.loadOlder')} )} diff --git a/web/src/components/AssistantChat/StatusBar.tsx b/web/src/components/AssistantChat/StatusBar.tsx index 0cb4928e..584b565c 100644 --- a/web/src/components/AssistantChat/StatusBar.tsx +++ b/web/src/components/AssistantChat/StatusBar.tsx @@ -3,6 +3,7 @@ import type { PermissionModeTone } from '@hapi/protocol' import { useMemo } from 'react' import type { AgentState, ModelMode, PermissionMode } from '@/types/api' import { getContextBudgetTokens } from '@/chat/modelConfig' +import { useTranslation } from '@/lib/use-translation' // Vibing messages for thinking state const VIBING_MESSAGES = [ @@ -33,13 +34,14 @@ const PERMISSION_TONE_CLASSES: Record = { function getConnectionStatus( active: boolean, thinking: boolean, - agentState: AgentState | null | undefined + agentState: AgentState | null | undefined, + t: (key: string) => string ): { text: string; color: string; dotColor: string; isPulsing: boolean } { const hasPermissions = agentState?.requests && Object.keys(agentState.requests).length > 0 if (!active) { return { - text: 'offline', + text: t('misc.offline'), color: 'text-[#999]', dotColor: 'bg-[#999]', isPulsing: false @@ -48,7 +50,7 @@ function getConnectionStatus( if (hasPermissions) { return { - text: 'permission required', + text: t('misc.permissionRequired'), color: 'text-[#FF9500]', dotColor: 'bg-[#FF9500]', isPulsing: true @@ -66,23 +68,24 @@ function getConnectionStatus( } return { - text: 'online', + text: t('misc.online'), color: 'text-[#34C759]', dotColor: 'bg-[#34C759]', isPulsing: false } } -function getContextWarning(contextSize: number, maxContextSize: number): { text: string; color: string } | null { +function getContextWarning(contextSize: number, maxContextSize: number, t: (key: string, params?: Record) => string): { text: string; color: string } | null { const percentageUsed = (contextSize / maxContextSize) * 100 const percentageRemaining = Math.max(0, 100 - percentageUsed) + const percent = Math.round(percentageRemaining) if (percentageRemaining <= 5) { - return { text: `${Math.round(percentageRemaining)}% left`, color: 'text-red-500' } + return { text: t('misc.percentLeft', { percent }), color: 'text-red-500' } } else if (percentageRemaining <= 10) { - return { text: `${Math.round(percentageRemaining)}% left`, color: 'text-amber-500' } + return { text: t('misc.percentLeft', { percent }), color: 'text-amber-500' } } else { - return { text: `${Math.round(percentageRemaining)}% left`, color: 'text-[var(--app-hint)]' } + return { text: t('misc.percentLeft', { percent }), color: 'text-[var(--app-hint)]' } } } @@ -95,9 +98,10 @@ export function StatusBar(props: { permissionMode?: PermissionMode agentFlavor?: string | null }) { + const { t } = useTranslation() const connectionStatus = useMemo( - () => getConnectionStatus(props.active, props.thinking, props.agentState), - [props.active, props.thinking, props.agentState] + () => getConnectionStatus(props.active, props.thinking, props.agentState, t), + [props.active, props.thinking, props.agentState, t] ) const contextWarning = useMemo( @@ -105,9 +109,9 @@ export function StatusBar(props: { if (props.contextSize === undefined) return null const maxContextSize = getContextBudgetTokens(props.modelMode) if (!maxContextSize) return null - return getContextWarning(props.contextSize, maxContextSize) + return getContextWarning(props.contextSize, maxContextSize, t) }, - [props.contextSize, props.modelMode] + [props.contextSize, props.modelMode, t] ) const permissionMode = props.permissionMode diff --git a/web/src/components/CliOutputBlock.tsx b/web/src/components/CliOutputBlock.tsx index 36f96b1a..939696cc 100644 --- a/web/src/components/CliOutputBlock.tsx +++ b/web/src/components/CliOutputBlock.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react' import { stripAnsiAndControls } from '@/components/assistant-ui/markdown-utils' import { Card, CardHeader, CardTitle } from '@/components/ui/card' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { useTranslation } from '@/lib/use-translation' const CLI_TAG_PATTERN = '(?:local-command-[a-z-]+|command-(?:name|message|args))' const CLI_TAG_CHECK_REGEX = new RegExp(`<${CLI_TAG_PATTERN}>`, 'i') @@ -9,11 +10,11 @@ const CLI_TAG_REGEX_SOURCE = `<(${CLI_TAG_PATTERN})>([\\s\\S]*?)<\\/\\1>` const BR_REGEX = //gi const LABELS: Record = { - 'command-name': 'Command', - 'command-message': 'Command message', - 'command-args': 'Command args', - 'local-command-stdout': 'Stdout', - 'local-command-stderr': 'Stderr', + 'command-name': 'terminal.commandName', + 'command-message': 'terminal.commandMessage', + 'command-args': 'terminal.commandArgs', + 'local-command-stdout': 'terminal.stdout', + 'local-command-stderr': 'terminal.stderr', } const COMMAND_NAME_REGEX = /([\s\S]*?)<\/command-name>/i @@ -26,15 +27,15 @@ function normalizeCliText(text: string): string { return withoutAnsi.replace(BR_REGEX, '\n') } -function formatLabel(tag: string): string { +function formatLabel(tag: string, t?: (key: string) => string): string { const normalized = tag.toLowerCase() if (LABELS[normalized]) { - return LABELS[normalized] + return t ? t(LABELS[normalized]) : LABELS[normalized] } return normalized.replace(/-/g, ' ') } -function buildCliOutput(text: string): string { +function buildCliOutput(text: string, t?: (key: string) => string): string { const matches = Array.from(text.matchAll(new RegExp(CLI_TAG_REGEX_SOURCE, 'gi'))) if (matches.length === 0) { return normalizeCliText(text) @@ -54,7 +55,7 @@ function buildCliOutput(text: string): string { const tagName = match[1] ?? '' const content = normalizeCliText(match[2] ?? '') - const label = formatLabel(tagName) + const label = formatLabel(tagName, t) if (content.length > 0) { sections.push(`${label}:\n${content}`) @@ -101,7 +102,8 @@ function CliIcon() { } export function CliOutputBlock(props: { text: string }) { - const content = useMemo(() => buildCliOutput(props.text), [props.text]) + const { t } = useTranslation() + const content = useMemo(() => buildCliOutput(props.text, t), [props.text, t]) const commandName = useMemo(() => extractCommandName(props.text), [props.text]) return ( @@ -117,7 +119,7 @@ export function CliOutputBlock(props: { text: string }) {
- {commandName ?? 'CLI output'} + {commandName ?? t('terminal.commandName')}
@@ -129,7 +131,7 @@ export function CliOutputBlock(props: { text: string }) { - CLI output + {t('terminal.commandName')}
diff --git a/web/src/components/CodeBlock.tsx b/web/src/components/CodeBlock.tsx index f4ea8838..e979d867 100644 --- a/web/src/components/CodeBlock.tsx +++ b/web/src/components/CodeBlock.tsx @@ -1,12 +1,14 @@ import { useCopyToClipboard } from '@/hooks/useCopyToClipboard' import { useShikiHighlighter } from '@/lib/shiki' import { CopyIcon, CheckIcon } from '@/components/icons' +import { useTranslation } from '@/lib/use-translation' export function CodeBlock(props: { code: string language?: string showCopyButton?: boolean }) { + const { t } = useTranslation() const showCopyButton = props.showCopyButton ?? true const { copied, copy } = useCopyToClipboard() const highlighted = useShikiHighlighter(props.code, props.language) @@ -18,7 +20,7 @@ export function CodeBlock(props: { type="button" onClick={() => copy(props.code)} className="absolute right-1.5 top-1.5 rounded p-1 text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] transition-colors" - title="Copy" + title={t('code.copy')} > {copied ? : } diff --git a/web/src/components/DiffView.tsx b/web/src/components/DiffView.tsx index e1a897db..a24b6f10 100644 --- a/web/src/components/DiffView.tsx +++ b/web/src/components/DiffView.tsx @@ -3,6 +3,7 @@ import { useMemo } from 'react' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { usePointerFocusRing } from '@/hooks/usePointerFocusRing' import { cn } from '@/lib/utils' +import { useTranslation } from '@/lib/use-translation' export function DiffView(props: { oldString: string @@ -10,6 +11,7 @@ export function DiffView(props: { filePath?: string variant?: 'preview' | 'inline' }) { + const { t } = useTranslation() const variant = props.variant ?? 'preview' const { suppressFocusRing, onTriggerPointerDown, onTriggerKeyDown, onTriggerBlur } = usePointerFocusRing() @@ -21,8 +23,8 @@ export function DiffView(props: { return { oldChars, newChars, label: `old: ${oldLabel} → new: ${newLabel}` } }, [props.oldString.length, props.newString.length]) - const title = props.filePath ? props.filePath : 'Diff' - const subtitle = props.filePath ? stats.label : `Diff • ${stats.label}` + const title = props.filePath ? props.filePath : t('diff.title') + const subtitle = props.filePath ? stats.label : `${t('diff.title')} • ${stats.label}` const DiffInline = (
- View + {t('diff.view')}
diff --git a/web/src/components/InstallPrompt.tsx b/web/src/components/InstallPrompt.tsx index feaa6dd7..d790a60a 100644 --- a/web/src/components/InstallPrompt.tsx +++ b/web/src/components/InstallPrompt.tsx @@ -2,8 +2,10 @@ import { useState } from 'react' import { usePWAInstall } from '@/hooks/usePWAInstall' import { usePlatform } from '@/hooks/usePlatform' import { CloseIcon, ShareIcon, PlusCircleIcon } from '@/components/icons' +import { useTranslation } from '@/lib/use-translation' export function InstallPrompt() { + const { t } = useTranslation() const { canInstall, canInstallIOS, promptInstall, dismissInstall, isStandalone } = usePWAInstall() const { isTelegram, haptic } = usePlatform() const [showIOSGuide, setShowIOSGuide] = useState(false) @@ -20,7 +22,7 @@ export function InstallPrompt() {

- Install HAPI + {t('install.title')}

@@ -85,10 +87,10 @@ export function InstallPrompt() {

- Install HAPI + {t('install.title')}

- Add to home screen for the best experience + {t('install.description')}

+ + {isOpen && ( +
+ {locales.map((loc) => { + const isSelected = locale === loc.value + return ( + + ) + })} +
+ )} +
+ ) +} diff --git a/web/src/components/LoadingState.tsx b/web/src/components/LoadingState.tsx index e80b2c8d..975d7439 100644 --- a/web/src/components/LoadingState.tsx +++ b/web/src/components/LoadingState.tsx @@ -1,5 +1,6 @@ import { Spinner } from '@/components/Spinner' import { cn } from '@/lib/utils' +import { useTranslation } from '@/lib/use-translation' type LoadingStateProps = { label?: string @@ -8,10 +9,13 @@ type LoadingStateProps = { } export function LoadingState({ - label = 'Loading…', + label, className, spinnerSize = 'md' }: LoadingStateProps) { + const { t } = useTranslation() + const displayLabel = label ?? t('loading') + return (
- {label} + {displayLabel}
) } diff --git a/web/src/components/LoginPrompt.tsx b/web/src/components/LoginPrompt.tsx index bebd4708..b1f3bc7c 100644 --- a/web/src/components/LoginPrompt.tsx +++ b/web/src/components/LoginPrompt.tsx @@ -1,8 +1,10 @@ import { useCallback, useEffect, useState } from 'react' import { ApiClient } from '@/api/client' +import { LanguageSwitcher } from '@/components/LanguageSwitcher' import { Spinner } from '@/components/Spinner' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { useTranslation } from '@/lib/use-translation' import type { ServerUrlResult } from '@/hooks/useServerUrl' type LoginPromptProps = { @@ -17,6 +19,7 @@ type LoginPromptProps = { } export function LoginPrompt(props: LoginPromptProps) { + const { t } = useTranslation() const isBindMode = props.mode === 'bind' const [accessToken, setAccessToken] = useState('') const [isLoading, setIsLoading] = useState(false) @@ -30,7 +33,7 @@ export function LoginPrompt(props: LoginPromptProps) { const trimmedToken = accessToken.trim() if (!trimmedToken) { - setError('Please enter an access token') + setError(t('login.error.enterToken')) return } @@ -40,28 +43,28 @@ export function LoginPrompt(props: LoginPromptProps) { try { if (isBindMode) { if (!props.onBind) { - setError('Binding is unavailable.') + setError(t('login.error.bindingUnavailable')) return } await props.onBind(trimmedToken) } else { - // Validate the token by attempting to authenticate + // Validate token by attempting to authenticate const client = new ApiClient('', { baseUrl: props.baseUrl }) await client.authenticate({ accessToken: trimmedToken }) - // If successful, pass the token to parent + // If successful, pass token to parent if (!props.onLogin) { - setError('Login is unavailable.') + setError(t('login.error.loginUnavailable')) return } props.onLogin(trimmedToken) } } catch (e) { - const fallbackMessage = isBindMode ? 'Binding failed' : 'Authentication failed' + const fallbackMessage = isBindMode ? t('login.error.bindFailed') : t('login.error.authFailed') setError(e instanceof Error ? e.message : fallbackMessage) } finally { setIsLoading(false) } - }, [accessToken, props]) + }, [accessToken, props, t, isBindMode]) useEffect(() => { if (!isServerDialogOpen) { @@ -91,13 +94,18 @@ export function LoginPrompt(props: LoginPromptProps) { }, [props]) const displayError = error || props.error - const serverSummary = props.serverUrl ?? `${props.baseUrl} (same origin)` - const title = isBindMode ? 'Bind Telegram' : 'HAPI' - const subtitle = 'Vibe Coding Anytime, Anywhere' - const submitLabel = isBindMode ? 'Bind' : 'Sign In' + const serverSummary = props.serverUrl ?? `${props.baseUrl} ${t('login.server.default')}` + const title = isBindMode ? t('login.bind.title') : t('login.title') + const subtitle = t('login.subtitle') + const submitLabel = isBindMode ? t('login.bind.submit') : t('login.submit') return (
+ {/* Language switcher */} +
+ +
+
{/* Header */}
@@ -114,7 +122,7 @@ export function LoginPrompt(props: LoginPromptProps) { type="password" value={accessToken} onChange={(e) => setAccessToken(e.target.value)} - placeholder="Access token" + placeholder={t('login.placeholder')} autoComplete="current-password" disabled={isLoading} className="w-full px-3 py-2.5 rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] text-[var(--app-fg)] placeholder:text-[var(--app-hint)] focus:outline-none focus:ring-2 focus:ring-[var(--app-button)] focus:border-transparent disabled:opacity-50" @@ -136,7 +144,7 @@ export function LoginPrompt(props: LoginPromptProps) { {isLoading ? ( <> - {isBindMode ? 'Binding...' : 'Signing in...'} + {isBindMode ? t('login.bind.submitting') : t('login.submitting')} ) : ( submitLabel @@ -148,27 +156,27 @@ export function LoginPrompt(props: LoginPromptProps) { {!isBindMode && (
- Needs help? + {t('login.help')} - Server URL + {t('login.server.title')} - Set the hapi server origin for API and live updates. + {t('login.server.description')}
- Current: {serverSummary} + {t('login.server.current')} {serverSummary}
- +
- Use http(s) only. Any path is ignored. + {t('login.server.hint')}
@@ -193,11 +201,11 @@ export function LoginPrompt(props: LoginPromptProps) {
{props.serverUrl && ( )}
@@ -209,8 +217,8 @@ export function LoginPrompt(props: LoginPromptProps) { {/* Footer */}
-
Designed with for Vibe Coding
-
© {new Date().getFullYear()} HAPI
+
{t('login.footer')} {t('login.footer.for')}
+
{t('login.footer.copyright')} {new Date().getFullYear()} HAPI
) diff --git a/web/src/components/NewSession/ActionButtons.tsx b/web/src/components/NewSession/ActionButtons.tsx index c1b44b54..e35daede 100644 --- a/web/src/components/NewSession/ActionButtons.tsx +++ b/web/src/components/NewSession/ActionButtons.tsx @@ -1,5 +1,6 @@ import { Button } from '@/components/ui/button' import { Spinner } from '@/components/Spinner' +import { useTranslation } from '@/lib/use-translation' export function ActionButtons(props: { isPending: boolean @@ -8,6 +9,8 @@ export function ActionButtons(props: { onCancel: () => void onCreate: () => void }) { + const { t } = useTranslation() + return (
diff --git a/web/src/components/NewSession/AgentSelector.tsx b/web/src/components/NewSession/AgentSelector.tsx index d53a2758..aa3fbd98 100644 --- a/web/src/components/NewSession/AgentSelector.tsx +++ b/web/src/components/NewSession/AgentSelector.tsx @@ -1,14 +1,17 @@ import type { AgentType } from './types' +import { useTranslation } from '@/lib/use-translation' export function AgentSelector(props: { agent: AgentType isDisabled: boolean onAgentChange: (value: AgentType) => void }) { + const { t } = useTranslation() + return (
{(['claude', 'codex', 'gemini'] as const).map((agentType) => ( diff --git a/web/src/components/NewSession/DirectorySection.tsx b/web/src/components/NewSession/DirectorySection.tsx index f431b062..a322d13a 100644 --- a/web/src/components/NewSession/DirectorySection.tsx +++ b/web/src/components/NewSession/DirectorySection.tsx @@ -2,6 +2,7 @@ import type { KeyboardEvent as ReactKeyboardEvent } from 'react' import type { Suggestion } from '@/hooks/useActiveSuggestions' import { Autocomplete } from '@/components/ChatInput/Autocomplete' import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay' +import { useTranslation } from '@/lib/use-translation' export function DirectorySection(props: { directory: string @@ -16,15 +17,17 @@ export function DirectorySection(props: { onSuggestionSelect: (index: number) => void onPathClick: (path: string) => void }) { + const { t } = useTranslation() + return (
props.onDirectoryChange(event.target.value)} onKeyDown={props.onDirectoryKeyDown} @@ -48,7 +51,7 @@ export function DirectorySection(props: { {props.recentPaths.length > 0 && (
- Recent: + {t('newSession.recent')}:
{props.recentPaths.map((path) => (
diff --git a/web/src/components/SessionActionMenu.tsx b/web/src/components/SessionActionMenu.tsx index 9c4c2d4e..5d225602 100644 --- a/web/src/components/SessionActionMenu.tsx +++ b/web/src/components/SessionActionMenu.tsx @@ -8,6 +8,7 @@ import { type CSSProperties, type RefObject } from 'react' +import { useTranslation } from '@/lib/use-translation' type SessionActionMenuProps = { isOpen: boolean @@ -92,6 +93,7 @@ type MenuPosition = { } export function SessionActionMenu(props: SessionActionMenuProps) { + const { t } = useTranslation() const { isOpen, onClose, @@ -236,7 +238,7 @@ export function SessionActionMenu(props: SessionActionMenuProps) { id={headingId} className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wide text-[var(--app-hint)]" > - Session actions + {t('session.more')}
- Rename + {t('session.action.rename')} {sessionActive ? ( @@ -262,7 +264,7 @@ export function SessionActionMenu(props: SessionActionMenuProps) { onClick={handleArchive} > - Archive + {t('session.action.archive')} ) : ( )}
diff --git a/web/src/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx index e81a1078..b5d58e9e 100644 --- a/web/src/components/SessionHeader.tsx +++ b/web/src/components/SessionHeader.tsx @@ -6,6 +6,7 @@ import { useSessionActions } from '@/hooks/mutations/useSessionActions' import { SessionActionMenu } from '@/components/SessionActionMenu' import { RenameSessionDialog } from '@/components/RenameSessionDialog' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' +import { useTranslation } from '@/lib/use-translation' function getSessionTitle(session: Session): string { if (session.metadata?.name) { @@ -65,6 +66,7 @@ export function SessionHeader(props: { api: ApiClient | null onSessionDeleted?: () => void }) { + const { t } = useTranslation() const { session, api, onSessionDeleted } = props const title = useMemo(() => getSessionTitle(session), [session]) const worktreeBranch = session.metadata?.worktree?.branch @@ -122,9 +124,17 @@ export function SessionHeader(props: {
{title}
-
- {session.metadata?.path ?? session.id} - {worktreeBranch ? ` • worktree: ${worktreeBranch}` : ''} +
+ + + {session.metadata?.flavor?.trim() || 'unknown'} + + + {t('session.item.modelMode')}: {session.modelMode || 'default'} + + {worktreeBranch ? ( + {t('session.item.worktree')}: {worktreeBranch} + ) : null}
@@ -133,7 +143,7 @@ export function SessionHeader(props: { type="button" onClick={props.onViewFiles} className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]" - title="Files" + title={t('session.title')} > @@ -147,7 +157,7 @@ export function SessionHeader(props: { aria-expanded={menuOpen} aria-controls={menuOpen ? menuId : undefined} className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]" - title="More actions" + title={t('session.more')} > @@ -177,10 +187,10 @@ export function SessionHeader(props: { setArchiveOpen(false)} - title="Archive Session" - description={`Are you sure you want to archive "${title}"? This will disconnect the active session.`} - confirmLabel="Archive" - confirmingLabel="Archiving..." + title={t('dialog.archive.title')} + description={t('dialog.archive.description', { name: title })} + confirmLabel={t('dialog.archive.confirm')} + confirmingLabel={t('dialog.archive.confirming')} onConfirm={archiveSession} isPending={isPending} destructive @@ -189,10 +199,10 @@ export function SessionHeader(props: { setDeleteOpen(false)} - title="Delete Session" - description={`Are you sure you want to delete "${title}"? This action cannot be undone.`} - confirmLabel="Delete" - confirmingLabel="Deleting..." + title={t('dialog.delete.title')} + description={t('dialog.delete.description', { name: title })} + confirmLabel={t('dialog.delete.confirm')} + confirmingLabel={t('dialog.delete.confirming')} onConfirm={handleDelete} isPending={isPending} destructive diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 57ee840a..9e3c7d90 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -7,6 +7,7 @@ import { useSessionActions } from '@/hooks/mutations/useSessionActions' import { SessionActionMenu } from '@/components/SessionActionMenu' import { RenameSessionDialog } from '@/components/RenameSessionDialog' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' +import { useTranslation } from '@/lib/use-translation' type SessionGroup = { directory: string @@ -146,21 +147,17 @@ function getAgentLabel(session: SessionSummary): string { return 'unknown' } -function getModelLabel(session: SessionSummary): string { - return session.modelMode ?? 'default' -} - -function formatRelativeTime(value: number): string | null { +function formatRelativeTime(value: number, t: (key: string, params?: Record) => string): string | null { const ms = value < 1_000_000_000_000 ? value * 1000 : value if (!Number.isFinite(ms)) return null const delta = Date.now() - ms - if (delta < 60_000) return 'just now' + if (delta < 60_000) return t('session.time.justNow') const minutes = Math.floor(delta / 60_000) - if (minutes < 60) return `${minutes}m ago` + if (minutes < 60) return t('session.time.minutesAgo', { n: minutes }) const hours = Math.floor(minutes / 60) - if (hours < 24) return `${hours}h ago` + if (hours < 24) return t('session.time.hoursAgo', { n: hours }) const days = Math.floor(hours / 24) - if (days < 7) return `${days}d ago` + if (days < 7) return t('session.time.daysAgo', { n: days }) return new Date(ms).toLocaleDateString() } @@ -170,6 +167,7 @@ function SessionItem(props: { showPath?: boolean api: ApiClient | null }) { + const { t } = useTranslation() const { session: s, onSelect, showPath = true, api } = props const { haptic } = usePlatform() const [menuOpen, setMenuOpen] = useState(false) @@ -222,6 +220,11 @@ function SessionItem(props: {
+ {s.thinking ? ( + + {t('session.item.thinking')} + + ) : null} {(() => { const progress = getTodoProgress(s) if (!progress) return null @@ -234,11 +237,11 @@ function SessionItem(props: { })()} {s.pendingRequestsCount > 0 ? ( - pending {s.pendingRequestsCount} + {t('session.item.pending')} {s.pendingRequestsCount} ) : null} - {formatRelativeTime(s.updatedAt)} + {formatRelativeTime(s.updatedAt, t)}
@@ -254,9 +257,9 @@ function SessionItem(props: { {getAgentLabel(s)} - model: {getModelLabel(s)} + {t('session.item.modelMode')}: {s.modelMode || 'default'} {s.metadata?.worktree?.branch ? ( - worktree: {s.metadata.worktree.branch} + {t('session.item.worktree')}: {s.metadata.worktree.branch} ) : null}
@@ -283,10 +286,10 @@ function SessionItem(props: { setArchiveOpen(false)} - title="Archive Session" - description={`Are you sure you want to archive "${sessionName}"? This will disconnect the active session.`} - confirmLabel="Archive" - confirmingLabel="Archiving..." + title={t('dialog.archive.title')} + description={t('dialog.archive.description', { name: sessionName })} + confirmLabel={t('dialog.archive.confirm')} + confirmingLabel={t('dialog.archive.confirming')} onConfirm={archiveSession} isPending={isPending} destructive @@ -295,10 +298,10 @@ function SessionItem(props: { setDeleteOpen(false)} - title="Delete Session" - description={`Are you sure you want to delete "${sessionName}"? This action cannot be undone.`} - confirmLabel="Delete" - confirmingLabel="Deleting..." + title={t('dialog.delete.title')} + description={t('dialog.delete.description', { name: sessionName })} + confirmLabel={t('dialog.delete.confirm')} + confirmingLabel={t('dialog.delete.confirming')} onConfirm={deleteSession} isPending={isPending} destructive @@ -316,6 +319,7 @@ export function SessionList(props: { renderHeader?: boolean api: ApiClient | null }) { + const { t } = useTranslation() const { renderHeader = true, api } = props const groups = useMemo( () => groupSessionsByDirectory(props.sessions), @@ -359,13 +363,13 @@ export function SessionList(props: { {renderHeader ? (
- {props.sessions.length} sessions in {groups.length} projects + {t('sessions.count', { n: props.sessions.length, m: groups.length })}
diff --git a/web/src/components/Spinner.tsx b/web/src/components/Spinner.tsx index 5643fdbb..484c24b1 100644 --- a/web/src/components/Spinner.tsx +++ b/web/src/components/Spinner.tsx @@ -1,4 +1,5 @@ import { cn } from '@/lib/utils' +import { useTranslation } from '@/lib/use-translation' type SpinnerProps = { size?: 'sm' | 'md' | 'lg' @@ -11,12 +12,13 @@ export function Spinner({ className, label }: SpinnerProps) { + const { t } = useTranslation() const sizeClasses = { sm: 'h-4 w-4', md: 'h-5 w-5', lg: 'h-6 w-6' } - const effectiveLabel = label === undefined ? 'Loading' : label + const effectiveLabel = label === undefined ? t('loading') : label const accessibilityProps = effectiveLabel === null ? { 'aria-hidden': true } : { role: 'status', 'aria-label': effectiveLabel } diff --git a/web/src/components/SyncingBanner.tsx b/web/src/components/SyncingBanner.tsx index a34a60d8..8499b1e0 100644 --- a/web/src/components/SyncingBanner.tsx +++ b/web/src/components/SyncingBanner.tsx @@ -1,7 +1,9 @@ import { useOnlineStatus } from '@/hooks/useOnlineStatus' import { Spinner } from '@/components/Spinner' +import { useTranslation } from '@/lib/use-translation' export function SyncingBanner({ isSyncing }: { isSyncing: boolean }) { + const { t } = useTranslation() const isOnline = useOnlineStatus() // Don't show syncing banner when offline (OfflineBanner takes precedence) @@ -12,7 +14,7 @@ export function SyncingBanner({ isSyncing }: { isSyncing: boolean }) { return (
- Syncing… + {t('syncing.title')}
) } diff --git a/web/src/components/ToolCard/AskUserQuestionFooter.tsx b/web/src/components/ToolCard/AskUserQuestionFooter.tsx index 55ccc7a4..a8164711 100644 --- a/web/src/components/ToolCard/AskUserQuestionFooter.tsx +++ b/web/src/components/ToolCard/AskUserQuestionFooter.tsx @@ -7,6 +7,7 @@ import { isAskUserQuestionToolName, parseAskUserQuestionInput, type AskUserQuest import { cn } from '@/lib/utils' import { usePlatform } from '@/hooks/usePlatform' import { Spinner } from '@/components/Spinner' +import { useTranslation } from '@/lib/use-translation' function SelectionMark(props: { checked: boolean; mode: 'single' | 'multi' }) { const mark = props.mode === 'multi' @@ -80,6 +81,7 @@ export function AskUserQuestionFooter(props: { disabled: boolean onDone: () => void }) { + const { t } = useTranslation() const { haptic } = usePlatform() const permission = props.tool.permission const parsed = useMemo(() => parseAskUserQuestionInput(props.tool.input), [props.tool.input]) @@ -116,7 +118,7 @@ export function AskUserQuestionFooter(props: { props.onDone() } catch (e) { haptic.notification('error') - setError(e instanceof Error ? e.message : 'Request failed') + setError(e instanceof Error ? e.message : t('dialog.error.default')) } } @@ -149,7 +151,7 @@ export function AskUserQuestionFooter(props: { if (questions.length === 0) { const a0 = validateQuestion(0) if (!a0) { - setError('Please type an answer.') + setError(t('tool.selectOption')) return } answers['0'] = a0 @@ -157,7 +159,7 @@ export function AskUserQuestionFooter(props: { for (let i = 0; i < questions.length; i += 1) { const a = validateQuestion(i) if (!a) { - setError(`Please answer question ${i + 1} before submitting.`) + setError(t('tool.selectOption')) setStep(i) return } @@ -174,7 +176,7 @@ export function AskUserQuestionFooter(props: { if (questions.length === 0) return const a = validateQuestion(clampedStep) if (!a) { - setError('Please select at least one option or type an answer.') + setError(t('tool.selectOption')) return } setError(null) @@ -261,7 +263,7 @@ export function AskUserQuestionFooter(props: {
- Question + {t('tool.question')} [{clampedStep + 1}/{total}] @@ -279,13 +281,13 @@ export function AskUserQuestionFooter(props: { {questions.length === 0 ? (
- AskUserQuestion payload is not in the expected format. Type your answer: + {t('tool.askUserQuestion.fallback')}