diff --git a/scripts/i18n/scan-hardcoded.ts b/scripts/i18n/scan-hardcoded.ts index 4e067781..b1c99f04 100755 --- a/scripts/i18n/scan-hardcoded.ts +++ b/scripts/i18n/scan-hardcoded.ts @@ -34,8 +34,15 @@ const AREAS = [ * 'officer-web (shell)' landed 2026-08-23 (phase 2). It holds no never-translate hits of its own; what it * does hold is ~30 `` labels quoted from Google's console, which * `stripUntranslated` skips for the same reason. + * + * 'officerdev (panels)' landed 2026-08-23 (phase 3). It holds nine `new Error()` messages for whoever is + * reading a terminal, not a user — same never-translate shape as 'shared components'. Two more strings + * are marked `i18n-exempt` rather than never-translate: `useTaskRunner.ts`'s "Running:"/"Error:" framing + * is interleaved with raw stdout/stderr inside one monospace `
` feed, so translating the frame alone
+ * would read as broken mid-buffer — the same reasoning as the never-translate list, reached by a
+ * different mechanism because it's not a thrown Error.
  */
-const ZEROED: string[] = ['shared components', 'officer-web (shell)'];
+const ZEROED: string[] = ['shared components', 'officer-web (shell)', 'officerdev (panels)'];
 
 const SKIP = /node_modules|\.gen\.|[/\\]locales[/\\]|[/\\]vendor[/\\]|\.test\.|\.d\.ts$/;
 
diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/AgentRunnerModal.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/AgentRunnerModal.tsx
index cd34af44..0253abea 100644
--- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/AgentRunnerModal.tsx
+++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/AgentRunnerModal.tsx
@@ -8,6 +8,7 @@ import { useClient } from 'hooks/useClient';
 import { chatListPath } from '../../../ChatHistory/chat-routes';
 import type { AgentSummary } from '../../useAgents';
 import { TaskInputForm, type TaskInputDef } from './TaskRunnerModal';
+import { useTranslation } from '../../../../locales';
 
 // Starting an agent is not running a job: the POST returns as soon as the session is spawned, and the
 // run itself is watched in /chat. So this modal has no output pane — the transcript is the output,
@@ -32,6 +33,7 @@ type AgentRunnerModalProps = {
 };
 
 export const AgentRunnerModal = ({ open, onOpenChange, agent, entryName, entryFullPath }: AgentRunnerModalProps) => {
+  const { t, tUi } = useTranslation();
   const client = useClient();
   const navigate = useNavigate();
   const [phase, setPhase] = useState('ready');
@@ -78,7 +80,7 @@ export const AgentRunnerModal = ({ open, onOpenChange, agent, entryName, entryFu
       setResult(started);
       setPhase('started');
     } catch (err) {
-      setError(err instanceof Error ? err.message : 'Failed to start the agent');
+      setError(err instanceof Error ? err.message : t('agentRunner.startFailed'));
       setPhase('failed');
     }
   };
@@ -122,9 +124,11 @@ export const AgentRunnerModal = ({ open, onOpenChange, agent, entryName, entryFu
             
-
Agent running
+
+ {t('agentRunner.agentRunning')} +
- It keeps going whether or not this stays open. Watch it in the chat — newest session on top. + {t('agentRunner.keepsGoing')}
@@ -132,13 +136,13 @@ export const AgentRunnerModal = ({ open, onOpenChange, agent, entryName, entryFu onClick={openChat} className="flex items-center gap-2 px-5 py-2 rounded-lg bg-duck-teal text-white text-sm font-medium hover:bg-duck-teal/90 cursor-pointer" > - Open chat + {t('agentRunner.openChat')}
@@ -146,7 +150,9 @@ export const AgentRunnerModal = ({ open, onOpenChange, agent, entryName, entryFu <> {/* Target — the path the agent is pointed at, exactly as the server will receive it. */}
- Target + + {t('agentRunner.target')} + {entryFullPath}
@@ -173,7 +179,7 @@ export const AgentRunnerModal = ({ open, onOpenChange, agent, entryName, entryFu className="flex items-center gap-2 px-6 py-2.5 rounded-lg bg-duck-teal text-white font-medium text-sm hover:bg-duck-teal/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer" > {phase === 'starting' ? : } - {phase === 'starting' ? 'Starting…' : 'Run agent'} + {phase === 'starting' ? t('agentRunner.starting') : t('agentRunner.runAgent')} diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/DictateDialog.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/DictateDialog.tsx index 11edb806..248afc3f 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/DictateDialog.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/DictateDialog.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { toast } from 'sonner'; import { blobToWav } from '../../../Chat/useAudioRecording'; +import { useTranslation } from '../../../../locales'; import type { UseFileBrowserAppType } from '../useFileBrowserApp'; type DictateDialogProps = { @@ -9,6 +10,7 @@ type DictateDialogProps = { }; export const DictateDialog = ({ fileBrowserManager }: DictateDialogProps) => { + const { t, tUi } = useTranslation(); const { showDictate, setShowDictate, handleUpload } = fileBrowserManager; const [recording, setRecording] = useState(false); const mediaRecorderRef = useRef(null); @@ -23,8 +25,8 @@ export const DictateDialog = ({ fileBrowserManager }: DictateDialogProps) => { // the effect cleanup then runs over the same refs. Closing an already-closed AudioContext rejects. const cleanup = () => { cancelAnimationFrame(animFrameRef.current); - mediaRecorderRef.current?.stream?.getTracks().forEach((t) => t.stop()); - streamRef.current?.getTracks().forEach((t) => t.stop()); + mediaRecorderRef.current?.stream?.getTracks().forEach((track) => track.stop()); + streamRef.current?.getTracks().forEach((track) => track.stop()); if (audioCtxRef.current && audioCtxRef.current.state !== 'closed') void audioCtxRef.current.close(); mediaRecorderRef.current = null; streamRef.current = null; @@ -63,7 +65,7 @@ export const DictateDialog = ({ fileBrowserManager }: DictateDialogProps) => { drawSpectrum(); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Could not access microphone'); + toast.error(err instanceof Error ? err.message : t('dictate.micAccessFailed')); setShowDictate(false); } }; @@ -119,12 +121,12 @@ export const DictateDialog = ({ fileBrowserManager }: DictateDialogProps) => { }); cancelAnimationFrame(animFrameRef.current); - recorder.stream.getTracks().forEach((t) => t.stop()); + recorder.stream.getTracks().forEach((track) => track.stop()); streamRef.current?.getTracks().forEach((t) => t.stop()); audioCtxRef.current?.close(); if (blob.size === 0) { - toast.error('No audio was captured'); + toast.error(t('dictate.noAudioCaptured')); cleanup(); setShowDictate(false); return; @@ -140,7 +142,7 @@ export const DictateDialog = ({ fileBrowserManager }: DictateDialogProps) => { setRecording(false); await handleUpload([file]); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Recording failed'); + toast.error(err instanceof Error ? err.message : t('dictate.recordingFailed')); cleanup(); setShowDictate(false); } @@ -181,14 +183,16 @@ export const DictateDialog = ({ fileBrowserManager }: DictateDialogProps) => { > - Dictate - Recording audio. Press Enter or click Stop to save. + {t('dictate.title')} + {t('dictate.description')}
{recording && } - {recording ? 'Recording...' : 'Starting...'} + + {recording ? t('dictate.recording') : t('dictate.startingStatus')} +
diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx index 8db09e1a..70c8bf81 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx @@ -5,6 +5,7 @@ import type { DirEntry } from '../../../../hooks/useFilesAPI'; import { getFileType } from '../../../FileViewer'; import type { UseFileBrowserAppType } from '../useFileBrowserApp'; import { FileItem } from './FileItem'; +import { useTranslation } from '../../../../locales'; type SortField = 'name' | 'size' | 'type' | 'date'; type SortDirection = 'asc' | 'desc'; @@ -47,6 +48,7 @@ const useColumnCount = (scrollRef: React.RefObject) => { }; export const FileGrid = ({ fileBrowserManager }: FileGridProps) => { + const { t } = useTranslation(); const { visibleEntries: entries, viewMode, @@ -184,8 +186,8 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => { timer = undefined; }; const onKey = (ev: KeyboardEvent) => { - const t = ev.target as HTMLElement; - if (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.closest('.monaco-editor')) return; + const target = ev.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.closest('.monaco-editor')) return; if (ev.ctrlKey || ev.metaKey || ev.altKey || ev.key.length !== 1) return; // printable, unmodified only ev.preventDefault(); @@ -224,7 +226,9 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => { }, [setSearchQuery, searchInputRef, setSelected]); if (entries.length === 0) { - return
This folder is empty
; + return ( +
{t('files.grid.empty')}
+ ); } const renderItem = (entry: DirEntry) => ( @@ -288,25 +292,25 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => { @@ -315,7 +319,7 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => { className="sticky top-0 z-10 bg-background/95 backdrop-blur-sm border-b border-duck-dark/10 flex items-center gap-2 px-3" style={{ height: HEADER_HEIGHT }} > - Sort: + {t('files.grid.sort')} {(['name', 'size', 'type', 'date'] as SortField[]).map((field) => ( ))} @@ -38,7 +42,7 @@ export const Header = ({ fileBrowserManager }: HeaderProps) => { type="text" value={searchQuery} onChange={(ev) => setSearchQuery(ev.target.value)} - placeholder="Search..." + placeholder={t('fileBrowserWidget.search')} className="w-full rounded-lg border border-duck-dark/20 bg-background pl-8 pr-8 py-1 text-xs text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50" /> {searchQuery && ( diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserWidget/components/PinnedTab.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserWidget/components/PinnedTab.tsx index bcf7bcc2..1f3c9dbb 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserWidget/components/PinnedTab.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserWidget/components/PinnedTab.tsx @@ -1,4 +1,5 @@ import type { UseFileBrowserWidgetType } from '../useFileBrowserWidget'; +import { useTranslation } from '../../../../locales'; import { EntryRow } from './EntryRow'; type PinnedTabProps = { @@ -6,10 +7,11 @@ type PinnedTabProps = { }; export const PinnedTab = ({ fileBrowserManager }: PinnedTabProps) => { + const { t } = useTranslation(); const { pinned, openFile, togglePin } = fileBrowserManager; if (pinned.length === 0) { - return

No pinned files

; + return

{t('fileBrowserWidget.noPinnedFiles')}

; } return ( diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserWidget/components/RecentTab.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserWidget/components/RecentTab.tsx index 9caf75fb..361e6dea 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserWidget/components/RecentTab.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserWidget/components/RecentTab.tsx @@ -1,4 +1,5 @@ import type { UseFileBrowserWidgetType } from '../useFileBrowserWidget'; +import { useTranslation } from '../../../../locales'; import { EntryRow } from './EntryRow'; type RecentTabProps = { @@ -6,10 +7,11 @@ type RecentTabProps = { }; export const RecentTab = ({ fileBrowserManager }: RecentTabProps) => { + const { t } = useTranslation(); const { recents, openFile, isPinned, togglePin } = fileBrowserManager; if (recents.length === 0) { - return

No recent files

; + return

{t('fileBrowserWidget.noRecentFiles')}

; } return ( diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserWidget/components/SearchResults.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserWidget/components/SearchResults.tsx index 068222ef..842f9da9 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserWidget/components/SearchResults.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserWidget/components/SearchResults.tsx @@ -1,5 +1,6 @@ import { Loader2 } from 'lucide-react'; import type { UseFileBrowserWidgetType } from '../useFileBrowserWidget'; +import { useTranslation } from '../../../../locales'; import { EntryRow } from './EntryRow'; type SearchResultsProps = { @@ -7,6 +8,7 @@ type SearchResultsProps = { }; export const SearchResults = ({ fileBrowserManager }: SearchResultsProps) => { + const { t } = useTranslation(); const { searching, searchResults, openFile, isPinned, togglePin } = fileBrowserManager; if (searching) { @@ -18,7 +20,7 @@ export const SearchResults = ({ fileBrowserManager }: SearchResultsProps) => { } if (searchResults.length === 0) { - return

No results

; + return

{t('fileBrowserWidget.noResults')}

; } return ( diff --git a/src/workspaces/officerdev/src/locales/en-GB.json b/src/workspaces/officerdev/src/locales/en-GB.json index 53cca5ba..8f0b02cf 100644 --- a/src/workspaces/officerdev/src/locales/en-GB.json +++ b/src/workspaces/officerdev/src/locales/en-GB.json @@ -527,6 +527,14 @@ "clearSelection": "Clear selection", "selected": "selected", "refresh": "Refresh" + }, + "grid": { + "empty": "This folder is empty", + "sort": "Sort:", + "name": "Name", + "type": "Type", + "size": "Size", + "date": "Date" } }, "taskRunner": { @@ -607,5 +615,38 @@ "timedOut": "Timed out", "downloadFailed": "Download failed", "couldNotStart": "Could not start the download" + }, + "fileBrowserWidget": { + "title": "File Browser", + "search": "Search...", + "browse": "Browse", + "recent": "Recent", + "pinned": "Pinned", + "noResults": "No results", + "emptyDirectory": "Empty directory", + "noPinnedFiles": "No pinned files", + "noRecentFiles": "No recent files", + "uploading": "Uploading...", + "empty": "This folder is empty", + "sort": "Sort:" + }, + "agentRunner": { + "agentRunning": "Agent running", + "keepsGoing": "It keeps going whether or not this stays open. Watch it in the chat — newest session on top.", + "openChat": "Open chat", + "target": "Target", + "starting": "Starting…", + "runAgent": "Run agent", + "startFailed": "Failed to start the agent" + }, + "dictate": { + "title": "Dictate", + "description": "Recording audio. Press Enter or click Stop to save.", + "recording": "Recording...", + "startingStatus": "Starting...", + "stopSave": "Stop & Save", + "noAudioCaptured": "No audio was captured", + "micAccessFailed": "Could not access microphone", + "recordingFailed": "Recording failed" } } diff --git a/src/workspaces/officerdev/src/locales/pt-PT.json b/src/workspaces/officerdev/src/locales/pt-PT.json index 3f8518eb..bf1fc449 100644 --- a/src/workspaces/officerdev/src/locales/pt-PT.json +++ b/src/workspaces/officerdev/src/locales/pt-PT.json @@ -527,6 +527,14 @@ "clearSelection": "Limpar seleção", "selected": "selecionados", "refresh": "Atualizar" + }, + "grid": { + "empty": "Esta pasta está vazia", + "sort": "Ordenar:", + "name": "Nome", + "type": "Tipo", + "size": "Tamanho", + "date": "Data" } }, "taskRunner": { @@ -607,5 +615,38 @@ "timedOut": "Tempo esgotado", "downloadFailed": "Falha ao descarregar", "couldNotStart": "Não foi possível iniciar o descarregamento" + }, + "fileBrowserWidget": { + "title": "Explorador de ficheiros", + "search": "Pesquisar...", + "browse": "Explorar", + "recent": "Recentes", + "pinned": "Fixados", + "noResults": "Sem resultados", + "emptyDirectory": "Diretório vazio", + "noPinnedFiles": "Sem ficheiros fixados", + "noRecentFiles": "Sem ficheiros recentes", + "uploading": "A carregar...", + "empty": "Esta pasta está vazia", + "sort": "Ordenar:" + }, + "agentRunner": { + "agentRunning": "Agente em execução", + "keepsGoing": "Continua a correr quer esta janela fique aberta quer não. Acompanhe-o no chat — a sessão mais recente fica no topo.", + "openChat": "Abrir chat", + "target": "Alvo", + "starting": "A iniciar…", + "runAgent": "Executar agente", + "startFailed": "Falha ao iniciar o agente" + }, + "dictate": { + "title": "Ditado", + "description": "A gravar áudio. Prima Enter ou clique em Parar para guardar.", + "recording": "A gravar...", + "startingStatus": "A iniciar...", + "stopSave": "Parar e guardar", + "noAudioCaptured": "Não foi captado áudio", + "micAccessFailed": "Não foi possível aceder ao microfone", + "recordingFailed": "Falha na gravação" } }