From bde59eb74eec0b1fe83b095bc21336a6083602a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 23 Aug 2026 05:37:04 +0000 Subject: [PATCH] activity, capabilities, chat history and the locale picker (21 -> 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The locale picker's labels are now AUTONYMS computed from Intl -- each language named in its own language, so "Português europeu" reads as Portuguese whichever locale the UI is in. That is the convention for a language picker and the useful behaviour: somebody who cannot read the current language still has to find theirs. CapabilityPage interpolated the capability KIND into six strings by lowercasing it in code. "skill" does not lowercase into a Portuguese noun, so kind is a parameter now. What is left is two strings, both deliberate: see the next commit message. --- .../Dashboard/Activity/ActivityScreen.tsx | 30 +++++++++++-------- .../Screens/Dashboard/CapabilityPage.tsx | 24 ++++++++------- .../Screens/Dashboard/ChatHistory/index.tsx | 4 ++- .../Screens/Dashboard/CodeEditor/index.tsx | 20 ++++++++----- .../Screens/Dashboard/PasskeyGate/index.tsx | 4 ++- src/apps/officer-web/lib/locales.ts | 21 ++++++++++--- src/apps/officer-web/locales/en-GB.json | 28 ++++++++++++++++- src/apps/officer-web/locales/pt-PT.json | 28 ++++++++++++++++- 8 files changed, 120 insertions(+), 39 deletions(-) diff --git a/src/apps/officer-web/Screens/Dashboard/Activity/ActivityScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Activity/ActivityScreen.tsx index efdc59a6..f6a442c9 100644 --- a/src/apps/officer-web/Screens/Dashboard/Activity/ActivityScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Activity/ActivityScreen.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { Link, useParams } from 'react-router'; import { useClient } from 'hooks/useClient'; import { Radio, FileText, Activity as ActivityIcon } from 'lucide-react'; +import { useTranslation } from '@/lib/i18n'; type TaskRow = { id: string; source: 'harness'; cwd: string; sizeBytes: number; updatedAt: number; active: boolean }; type DetachedRow = { id: string; source: 'detached'; path: string; announcedAt: number }; @@ -15,6 +16,7 @@ const MAX_LINES = 600; // params (`task=` for a harness file, `path=` for a detached job), so the URL carries the id only and // the screen re-derives the param from the registry row — one address shape for both kinds. export const ActivityScreen = () => { + const { t } = useTranslation(); const { token, get } = useClient(); const [reg, setReg] = useState({ tasks: [], detached: [] }); const [regLoaded, setRegLoaded] = useState(false); @@ -87,30 +89,32 @@ export const ActivityScreen = () => {
@@ -452,7 +455,7 @@ export const CapabilityList = ({

No {kind.toLowerCase()}s found

)} {items.length > 0 && filtered.length === 0 && ( -

No matches

+

{t('capability.noMatches')}

)} @@ -469,6 +472,7 @@ export const CapabilityList = ({ * them for free — no reset effect racing the navigation. `edit` also makes the editor pane linkable. */ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: CapabilityPageProps) => { + const { t, tUi } = useTranslation(); const client = useClient(); const qc = useQueryClient(); const navigate = useNavigate(); @@ -497,7 +501,7 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit navigate(basePath, { replace: true }); await qc.invalidateQueries({ queryKey: [queryKey] }); } catch { - toast.error(`Failed to delete ${kind}`); + toast.error(t('capability.deleteFailed', { kind: kind.toLowerCase() })); } }; @@ -526,7 +530,7 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit - {detail?.name ?? `Select a ${kind.toLowerCase()}`} + {detail?.name ?? t('capability.selectOne', { kind: kind.toLowerCase() })} {detail && ( <> @@ -554,10 +558,10 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit ) : detail ? ( -

Empty file

+

{t('capability.emptyFile')}

) : (

- Select a {kind.toLowerCase()} to view its contents + {t('capability.selectToView', { kind: kind.toLowerCase() })}

)} @@ -606,10 +610,10 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx index bcee9383..20767e76 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx @@ -9,6 +9,7 @@ import { errorText } from 'helpers/error-text'; import { useDashboardState } from 'state/useDashboardState'; import type { ClaudeSessionDetail } from 'state/useClaudeSessions'; import { defaultLayout, hasAppType } from './defaultLayout'; +import { useTranslation } from '@/lib/i18n'; // How many messages to render on first open (anchored to the bottom); scroll-up pages older ones in. const CHAT_TAIL = 20; @@ -18,6 +19,7 @@ type SessionListPageProps = { }; export const SessionListPage = ({ isNew }: SessionListPageProps) => { + const { t } = useTranslation(); // The group is a path suffix now, not ?cwd= — see officerdev/apps/ChatHistory/chat-routes.ts. const { sessionId, '*': splat } = useParams<{ sessionId: string; '*': string }>(); const groupCwd = cwdFromSplat(splat); @@ -122,7 +124,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => { // Falling back to a bare id still opens a usable pane, but silently: you get an empty chat and // no hint that the transcript could not be read, which is indistinguishable from a new session. // Most often the id is stale — the transcript was deleted or pruned out from under the link. - toast.error(`Could not load this conversation: ${errorText(err, 'not found')}`); + toast.error(t('chatHistory.loadFailed', { reason: errorText(err, 'not found') })); setSelected({ id: sessionId }); } })(); diff --git a/src/apps/officer-web/Screens/Dashboard/CodeEditor/index.tsx b/src/apps/officer-web/Screens/Dashboard/CodeEditor/index.tsx index f9532b89..588df4e9 100644 --- a/src/apps/officer-web/Screens/Dashboard/CodeEditor/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/CodeEditor/index.tsx @@ -1,12 +1,16 @@ import { CodeEditorView } from 'officerdev'; import { Widget } from 'widgets/Widget'; +import { useTranslation } from '@/lib/i18n'; -export const CodeEditor = () => ( -
- - {/* The screen is the one editor that owns the address bar, so the open file is `?file=`. The panel +export const CodeEditor = () => { + const { t } = useTranslation(); + return ( +
+ + {/* The screen is the one editor that owns the address bar, so the open file is `?file=`. The panel wrapper deliberately does not pass this: a dashboard can hold two editors. */} - - -
-); + +
+
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/PasskeyGate/index.tsx b/src/apps/officer-web/Screens/Dashboard/PasskeyGate/index.tsx index c425e6dd..2420fb00 100644 --- a/src/apps/officer-web/Screens/Dashboard/PasskeyGate/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/PasskeyGate/index.tsx @@ -4,8 +4,10 @@ import { KeyRound } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/Card'; import { useAuth } from 'hooks/useAuth'; +import { useTranslation } from '@/lib/i18n'; export const PasskeyGate = () => { + const { t } = useTranslation(); const { user, createPasskeyCredentials } = useAuth(); const [isRegistering, setIsRegistering] = useState(false); @@ -31,7 +33,7 @@ export const PasskeyGate = () => { -

Set up your passkey

+

{t('passkeyGate.setUp')}

Passkeys provide a secure, passwordless way to access your account. Each device or browser needs its own diff --git a/src/apps/officer-web/lib/locales.ts b/src/apps/officer-web/lib/locales.ts index cb85db44..96126a69 100644 --- a/src/apps/officer-web/lib/locales.ts +++ b/src/apps/officer-web/lib/locales.ts @@ -6,9 +6,22 @@ */ export type ShippedLocale = { code: string; label: string }; -export const SHIPPED_LOCALES: ShippedLocale[] = [ - { code: 'en-GB', label: 'English (UK)' }, - { code: 'pt-PT', label: 'Português (Portugal)' }, -]; +/** + * The label is an AUTONYM — each language named in its own language, so "Português" reads as Portuguese + * whichever locale the UI is currently in. That is the convention for a language picker and it is the + * useful behaviour: somebody who cannot read the current language still has to find theirs. + * + * Computed rather than written down, for the same reason the spoken-language list is (see Languages.tsx): + * `Intl` already knows every language's name in every locale, including its own. + */ +const autonym = (code: string): string => { + const name = new Intl.DisplayNames([code], { type: 'language', fallback: 'code' }).of(code) ?? code; + return name.charAt(0).toUpperCase() + name.slice(1); +}; + +export const SHIPPED_LOCALES: ShippedLocale[] = ['en-GB', 'pt-PT'].map((code) => ({ + code, + label: autonym(code), +})); export const FALLBACK_LOCALE = 'en-GB'; diff --git a/src/apps/officer-web/locales/en-GB.json b/src/apps/officer-web/locales/en-GB.json index b4a502f1..65ccfa52 100644 --- a/src/apps/officer-web/locales/en-GB.json +++ b/src/apps/officer-web/locales/en-GB.json @@ -484,7 +484,8 @@ "calendar": "Calendar", "contacts": "Contacts", "appStore": "App store", - "marketplace": "Marketplace" + "marketplace": "Marketplace", + "codeEditor": "Code Editor" }, "theme": { "pillTitle": "Theme: {{name}} · ctrl+shift+←/→ cycle · ctrl+shift+space browse · ctrl+shift+f {{starAction}}", @@ -672,5 +673,30 @@ "interrupted": "Interrupted" }, "failedWithCode": "Failed (exit {{code}})" + }, + "activity": { + "title": "Activity", + "backgroundTasks": "Background tasks", + "detached": "Detached", + "backToList": "Back to the list", + "selectTask": "Select a task to follow its live output", + "noneRunning": "none running" + }, + "passkeyGate": { + "setUp": "Set up your passkey" + }, + "chatHistory": { + "loadFailed": "Could not load this conversation: {{reason}}" + }, + "capability": { + "frontmatter": "Frontmatter", + "noMatches": "No matches", + "emptyFile": "Empty file", + "createFailed": "Could not create the {{kind}}", + "deleteFailed": "Could not delete the {{kind}}", + "searchPlaceholder": "Search {{kind}}…", + "selectOne": "Select a {{kind}}", + "selectToView": "Select a {{kind}} to view its contents", + "delete": "Delete" } } diff --git a/src/apps/officer-web/locales/pt-PT.json b/src/apps/officer-web/locales/pt-PT.json index 2e7be6e3..809d35f7 100644 --- a/src/apps/officer-web/locales/pt-PT.json +++ b/src/apps/officer-web/locales/pt-PT.json @@ -484,7 +484,8 @@ "calendar": "Calendário", "contacts": "Contactos", "appStore": "Loja de aplicações", - "marketplace": "Marketplace" + "marketplace": "Marketplace", + "codeEditor": "Editor de código" }, "theme": { "pillTitle": "Tema: {{name}} · ctrl+shift+←/→ percorrer · ctrl+shift+space explorar · ctrl+shift+f {{starAction}}", @@ -672,5 +673,30 @@ "interrupted": "Interrompido" }, "failedWithCode": "Falhou (saída {{code}})" + }, + "activity": { + "title": "Atividade", + "backgroundTasks": "Tarefas em segundo plano", + "detached": "Destacada", + "backToList": "Voltar à lista", + "selectTask": "Selecione uma tarefa para seguir a sua saída ao vivo", + "noneRunning": "nenhuma em execução" + }, + "passkeyGate": { + "setUp": "Configure a sua passkey" + }, + "chatHistory": { + "loadFailed": "Não foi possível carregar esta conversa: {{reason}}" + }, + "capability": { + "frontmatter": "Frontmatter", + "noMatches": "Sem correspondências", + "emptyFile": "Ficheiro vazio", + "createFailed": "Não foi possível criar o {{kind}}", + "deleteFailed": "Não foi possível eliminar o {{kind}}", + "searchPlaceholder": "Pesquisar {{kind}}…", + "selectOne": "Selecione um {{kind}}", + "selectToView": "Selecione um {{kind}} para ver o seu conteúdo", + "delete": "Eliminar" } }