localization: officerdev (panels) to zero convertible
Last stretch of phase 3: FileBrowserWidget's five components (tabs, search, empty states), FileGrid (column headers, sort labels, empty folder), UploadProgress, DictateDialog and AgentRunnerModal. Reused ui.button.cancel/close from the shared catalog where the word was already there instead of adding panel-local duplicates. The two remaining scanner hits — useTaskRunner's "Running:"/"Error:" framing — are marked i18n-exempt rather than converted: they're interleaved with raw stdout/stderr inside one monospace <pre> feed, so translating the frame alone would read as broken mid-buffer. Same reasoning as the never-translate thrown-Error list, just not a thrown Error. officerdev (panels) added to the scanner's ZEROED guard — the check now holds three areas at zero.
This commit is contained in:
@@ -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
|
* 'officer-web (shell)' landed 2026-08-23 (phase 2). It holds no never-translate hits of its own; what it
|
||||||
* does hold is ~30 `<Untranslated reason="foreign-ui-label">` labels quoted from Google's console, which
|
* does hold is ~30 `<Untranslated reason="foreign-ui-label">` labels quoted from Google's console, which
|
||||||
* `stripUntranslated` skips for the same reason.
|
* `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 `<pre>` 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$/;
|
const SKIP = /node_modules|\.gen\.|[/\\]locales[/\\]|[/\\]vendor[/\\]|\.test\.|\.d\.ts$/;
|
||||||
|
|
||||||
|
|||||||
+13
-7
@@ -8,6 +8,7 @@ import { useClient } from 'hooks/useClient';
|
|||||||
import { chatListPath } from '../../../ChatHistory/chat-routes';
|
import { chatListPath } from '../../../ChatHistory/chat-routes';
|
||||||
import type { AgentSummary } from '../../useAgents';
|
import type { AgentSummary } from '../../useAgents';
|
||||||
import { TaskInputForm, type TaskInputDef } from './TaskRunnerModal';
|
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
|
// 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,
|
// 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) => {
|
export const AgentRunnerModal = ({ open, onOpenChange, agent, entryName, entryFullPath }: AgentRunnerModalProps) => {
|
||||||
|
const { t, tUi } = useTranslation();
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [phase, setPhase] = useState<Phase>('ready');
|
const [phase, setPhase] = useState<Phase>('ready');
|
||||||
@@ -78,7 +80,7 @@ export const AgentRunnerModal = ({ open, onOpenChange, agent, entryName, entryFu
|
|||||||
setResult(started);
|
setResult(started);
|
||||||
setPhase('started');
|
setPhase('started');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to start the agent');
|
setError(err instanceof Error ? err.message : t('agentRunner.startFailed'));
|
||||||
setPhase('failed');
|
setPhase('failed');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -122,9 +124,11 @@ export const AgentRunnerModal = ({ open, onOpenChange, agent, entryName, entryFu
|
|||||||
<div className="flex flex-col items-center justify-center gap-4 p-8 text-center">
|
<div className="flex flex-col items-center justify-center gap-4 p-8 text-center">
|
||||||
<CircleCheck className="h-10 w-10 text-duck-teal" />
|
<CircleCheck className="h-10 w-10 text-duck-teal" />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-base font-medium text-duck-dark dark:text-foreground">Agent running</div>
|
<div className="text-base font-medium text-duck-dark dark:text-foreground">
|
||||||
|
{t('agentRunner.agentRunning')}
|
||||||
|
</div>
|
||||||
<div className="text-sm text-duck-dark/50 dark:text-foreground/50 mt-1">
|
<div className="text-sm text-duck-dark/50 dark:text-foreground/50 mt-1">
|
||||||
It keeps going whether or not this stays open. Watch it in the chat — newest session on top.
|
{t('agentRunner.keepsGoing')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -132,13 +136,13 @@ export const AgentRunnerModal = ({ open, onOpenChange, agent, entryName, entryFu
|
|||||||
onClick={openChat}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-4 w-4" /> Open chat
|
<ExternalLink className="h-4 w-4" /> {t('agentRunner.openChat')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
className="px-5 py-2 rounded-lg text-sm font-medium text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 cursor-pointer"
|
className="px-5 py-2 rounded-lg text-sm font-medium text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 cursor-pointer"
|
||||||
>
|
>
|
||||||
Close
|
{tUi('button.close')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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 — the path the agent is pointed at, exactly as the server will receive it. */}
|
||||||
<div className="px-5 py-3 border-b border-duck-dark/10 flex flex-col gap-1">
|
<div className="px-5 py-3 border-b border-duck-dark/10 flex flex-col gap-1">
|
||||||
<span className="text-xs font-medium text-duck-dark/70 dark:text-foreground/70">Target</span>
|
<span className="text-xs font-medium text-duck-dark/70 dark:text-foreground/70">
|
||||||
|
{t('agentRunner.target')}
|
||||||
|
</span>
|
||||||
<span className="text-xs font-mono break-all text-duck-dark dark:text-foreground">{entryFullPath}</span>
|
<span className="text-xs font-mono break-all text-duck-dark dark:text-foreground">{entryFullPath}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -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"
|
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' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
|
{phase === 'starting' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
|
||||||
{phase === 'starting' ? 'Starting…' : 'Run agent'}
|
{phase === 'starting' ? t('agentRunner.starting') : t('agentRunner.runAgent')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+15
-11
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { blobToWav } from '../../../Chat/useAudioRecording';
|
import { blobToWav } from '../../../Chat/useAudioRecording';
|
||||||
|
import { useTranslation } from '../../../../locales';
|
||||||
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
|
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
|
||||||
|
|
||||||
type DictateDialogProps = {
|
type DictateDialogProps = {
|
||||||
@@ -9,6 +10,7 @@ type DictateDialogProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DictateDialog = ({ fileBrowserManager }: DictateDialogProps) => {
|
export const DictateDialog = ({ fileBrowserManager }: DictateDialogProps) => {
|
||||||
|
const { t, tUi } = useTranslation();
|
||||||
const { showDictate, setShowDictate, handleUpload } = fileBrowserManager;
|
const { showDictate, setShowDictate, handleUpload } = fileBrowserManager;
|
||||||
const [recording, setRecording] = useState(false);
|
const [recording, setRecording] = useState(false);
|
||||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
const mediaRecorderRef = useRef<MediaRecorder | null>(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.
|
// the effect cleanup then runs over the same refs. Closing an already-closed AudioContext rejects.
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
cancelAnimationFrame(animFrameRef.current);
|
cancelAnimationFrame(animFrameRef.current);
|
||||||
mediaRecorderRef.current?.stream?.getTracks().forEach((t) => t.stop());
|
mediaRecorderRef.current?.stream?.getTracks().forEach((track) => track.stop());
|
||||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
streamRef.current?.getTracks().forEach((track) => track.stop());
|
||||||
if (audioCtxRef.current && audioCtxRef.current.state !== 'closed') void audioCtxRef.current.close();
|
if (audioCtxRef.current && audioCtxRef.current.state !== 'closed') void audioCtxRef.current.close();
|
||||||
mediaRecorderRef.current = null;
|
mediaRecorderRef.current = null;
|
||||||
streamRef.current = null;
|
streamRef.current = null;
|
||||||
@@ -63,7 +65,7 @@ export const DictateDialog = ({ fileBrowserManager }: DictateDialogProps) => {
|
|||||||
|
|
||||||
drawSpectrum();
|
drawSpectrum();
|
||||||
} catch (err) {
|
} 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);
|
setShowDictate(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -119,12 +121,12 @@ export const DictateDialog = ({ fileBrowserManager }: DictateDialogProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
cancelAnimationFrame(animFrameRef.current);
|
cancelAnimationFrame(animFrameRef.current);
|
||||||
recorder.stream.getTracks().forEach((t) => t.stop());
|
recorder.stream.getTracks().forEach((track) => track.stop());
|
||||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||||
audioCtxRef.current?.close();
|
audioCtxRef.current?.close();
|
||||||
|
|
||||||
if (blob.size === 0) {
|
if (blob.size === 0) {
|
||||||
toast.error('No audio was captured');
|
toast.error(t('dictate.noAudioCaptured'));
|
||||||
cleanup();
|
cleanup();
|
||||||
setShowDictate(false);
|
setShowDictate(false);
|
||||||
return;
|
return;
|
||||||
@@ -140,7 +142,7 @@ export const DictateDialog = ({ fileBrowserManager }: DictateDialogProps) => {
|
|||||||
setRecording(false);
|
setRecording(false);
|
||||||
await handleUpload([file]);
|
await handleUpload([file]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'Recording failed');
|
toast.error(err instanceof Error ? err.message : t('dictate.recordingFailed'));
|
||||||
cleanup();
|
cleanup();
|
||||||
setShowDictate(false);
|
setShowDictate(false);
|
||||||
}
|
}
|
||||||
@@ -181,14 +183,16 @@ export const DictateDialog = ({ fileBrowserManager }: DictateDialogProps) => {
|
|||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Dictate</DialogTitle>
|
<DialogTitle>{t('dictate.title')}</DialogTitle>
|
||||||
<DialogDescription>Recording audio. Press Enter or click Stop to save.</DialogDescription>
|
<DialogDescription>{t('dictate.description')}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
<canvas ref={canvasRef} width={460} height={120} className="w-full rounded-md bg-duck-dark/5" />
|
<canvas ref={canvasRef} width={460} height={120} className="w-full rounded-md bg-duck-dark/5" />
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{recording && <span className="h-3 w-3 rounded-full bg-destructive animate-pulse" />}
|
{recording && <span className="h-3 w-3 rounded-full bg-destructive animate-pulse" />}
|
||||||
<span className="text-sm text-duck-dark/60">{recording ? 'Recording...' : 'Starting...'}</span>
|
<span className="text-sm text-duck-dark/60">
|
||||||
|
{recording ? t('dictate.recording') : t('dictate.startingStatus')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end w-full gap-2">
|
<div className="flex justify-end w-full gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -199,7 +203,7 @@ export const DictateDialog = ({ fileBrowserManager }: DictateDialogProps) => {
|
|||||||
}}
|
}}
|
||||||
className="px-4 py-2 text-sm rounded-md text-duck-dark/70 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
className="px-4 py-2 text-sm rounded-md text-duck-dark/70 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
{tUi('button.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -207,7 +211,7 @@ export const DictateDialog = ({ fileBrowserManager }: DictateDialogProps) => {
|
|||||||
disabled={!recording}
|
disabled={!recording}
|
||||||
className="px-4 py-2 text-sm rounded-md bg-duck-teal text-duck-yellow hover:bg-duck-teal/90 cursor-pointer transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
className="px-4 py-2 text-sm rounded-md bg-duck-teal text-duck-yellow hover:bg-duck-teal/90 cursor-pointer transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Stop & Save
|
{t('dictate.stopSave')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+12
-8
@@ -5,6 +5,7 @@ import type { DirEntry } from '../../../../hooks/useFilesAPI';
|
|||||||
import { getFileType } from '../../../FileViewer';
|
import { getFileType } from '../../../FileViewer';
|
||||||
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
|
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
|
||||||
import { FileItem } from './FileItem';
|
import { FileItem } from './FileItem';
|
||||||
|
import { useTranslation } from '../../../../locales';
|
||||||
|
|
||||||
type SortField = 'name' | 'size' | 'type' | 'date';
|
type SortField = 'name' | 'size' | 'type' | 'date';
|
||||||
type SortDirection = 'asc' | 'desc';
|
type SortDirection = 'asc' | 'desc';
|
||||||
@@ -47,6 +48,7 @@ const useColumnCount = (scrollRef: React.RefObject<HTMLDivElement | null>) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
visibleEntries: entries,
|
visibleEntries: entries,
|
||||||
viewMode,
|
viewMode,
|
||||||
@@ -184,8 +186,8 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
|||||||
timer = undefined;
|
timer = undefined;
|
||||||
};
|
};
|
||||||
const onKey = (ev: KeyboardEvent) => {
|
const onKey = (ev: KeyboardEvent) => {
|
||||||
const t = ev.target as HTMLElement;
|
const target = ev.target as HTMLElement;
|
||||||
if (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.closest('.monaco-editor')) return;
|
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
|
if (ev.ctrlKey || ev.metaKey || ev.altKey || ev.key.length !== 1) return; // printable, unmodified only
|
||||||
|
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
@@ -224,7 +226,9 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
|||||||
}, [setSearchQuery, searchInputRef, setSelected]);
|
}, [setSearchQuery, searchInputRef, setSelected]);
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return <div className="flex items-center justify-center py-20 text-duck-dark/40 text-sm">This folder is empty</div>;
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20 text-duck-dark/40 text-sm">{t('files.grid.empty')}</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderItem = (entry: DirEntry) => (
|
const renderItem = (entry: DirEntry) => (
|
||||||
@@ -288,25 +292,25 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
|||||||
<span className="shrink-0 w-5" />
|
<span className="shrink-0 w-5" />
|
||||||
<span className="shrink-0 w-5" />
|
<span className="shrink-0 w-5" />
|
||||||
<button className={`flex-1 min-w-0 ${sortBtnClass('name')}`} onClick={() => toggleSort('name')}>
|
<button className={`flex-1 min-w-0 ${sortBtnClass('name')}`} onClick={() => toggleSort('name')}>
|
||||||
Name {sortIcon('name')}
|
{t('files.grid.name')} {sortIcon('name')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`hidden md:flex shrink-0 w-20 justify-end ${sortBtnClass('type')}`}
|
className={`hidden md:flex shrink-0 w-20 justify-end ${sortBtnClass('type')}`}
|
||||||
onClick={() => toggleSort('type')}
|
onClick={() => toggleSort('type')}
|
||||||
>
|
>
|
||||||
Type {sortIcon('type')}
|
{t('files.grid.type')} {sortIcon('type')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`hidden md:flex shrink-0 w-20 justify-end ${sortBtnClass('size')}`}
|
className={`hidden md:flex shrink-0 w-20 justify-end ${sortBtnClass('size')}`}
|
||||||
onClick={() => toggleSort('size')}
|
onClick={() => toggleSort('size')}
|
||||||
>
|
>
|
||||||
Size {sortIcon('size')}
|
{t('files.grid.size')} {sortIcon('size')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`hidden md:flex shrink-0 w-28 justify-end ${sortBtnClass('date')}`}
|
className={`hidden md:flex shrink-0 w-28 justify-end ${sortBtnClass('date')}`}
|
||||||
onClick={() => toggleSort('date')}
|
onClick={() => toggleSort('date')}
|
||||||
>
|
>
|
||||||
Date {sortIcon('date')}
|
{t('files.grid.date')} {sortIcon('date')}
|
||||||
</button>
|
</button>
|
||||||
<span className="shrink-0 w-8" />
|
<span className="shrink-0 w-8" />
|
||||||
</div>
|
</div>
|
||||||
@@ -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"
|
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 }}
|
style={{ height: HEADER_HEIGHT }}
|
||||||
>
|
>
|
||||||
<span className="text-xs text-duck-dark/40">Sort:</span>
|
<span className="text-xs text-duck-dark/40">{t('files.grid.sort')}</span>
|
||||||
{(['name', 'size', 'type', 'date'] as SortField[]).map((field) => (
|
{(['name', 'size', 'type', 'date'] as SortField[]).map((field) => (
|
||||||
<button
|
<button
|
||||||
key={field}
|
key={field}
|
||||||
|
|||||||
+3
-1
@@ -1,10 +1,12 @@
|
|||||||
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
|
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
|
||||||
|
import { useTranslation } from '../../../../locales';
|
||||||
|
|
||||||
type UploadProgressProps = {
|
type UploadProgressProps = {
|
||||||
fileBrowserManager: UseFileBrowserAppType;
|
fileBrowserManager: UseFileBrowserAppType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UploadProgress = ({ fileBrowserManager }: UploadProgressProps) => {
|
export const UploadProgress = ({ fileBrowserManager }: UploadProgressProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { uploadProgress } = fileBrowserManager;
|
const { uploadProgress } = fileBrowserManager;
|
||||||
|
|
||||||
if (uploadProgress === null) return null;
|
if (uploadProgress === null) return null;
|
||||||
@@ -12,7 +14,7 @@ export const UploadProgress = ({ fileBrowserManager }: UploadProgressProps) => {
|
|||||||
return (
|
return (
|
||||||
<div className="shrink-0 px-4 pt-3">
|
<div className="shrink-0 px-4 pt-3">
|
||||||
<div className="flex items-center justify-between text-sm text-duck-dark/70 mb-1">
|
<div className="flex items-center justify-between text-sm text-duck-dark/70 mb-1">
|
||||||
<span>Uploading...</span>
|
<span>{t('fileBrowserWidget.uploading')}</span>
|
||||||
<span>{uploadProgress}%</span>
|
<span>{uploadProgress}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 rounded-full bg-duck-dark/10 overflow-hidden">
|
<div className="h-2 rounded-full bg-duck-dark/10 overflow-hidden">
|
||||||
|
|||||||
+2
@@ -34,6 +34,7 @@ export function useTaskRunner() {
|
|||||||
|
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'started':
|
case 'started':
|
||||||
|
// i18n-exempt: framing text inside one monospace terminal feed with raw stdout/stderr (see 'error' below).
|
||||||
setOutput((prev) => [...prev, { stream: 'system', text: `Running: ${msg.taskName}\n` }]);
|
setOutput((prev) => [...prev, { stream: 'system', text: `Running: ${msg.taskName}\n` }]);
|
||||||
break;
|
break;
|
||||||
case 'stdout':
|
case 'stdout':
|
||||||
@@ -47,6 +48,7 @@ export function useTaskRunner() {
|
|||||||
setPhase('done');
|
setPhase('done');
|
||||||
break;
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
|
// i18n-exempt: same terminal feed as 'started' above — content, not UI chrome.
|
||||||
setOutput((prev) => [...prev, { stream: 'stderr', text: `Error: ${msg.message}\n` }]);
|
setOutput((prev) => [...prev, { stream: 'stderr', text: `Error: ${msg.message}\n` }]);
|
||||||
setPhase('done');
|
setPhase('done');
|
||||||
setExitCode(-1);
|
setExitCode(-1);
|
||||||
|
|||||||
+3
-1
@@ -5,13 +5,15 @@ import { SearchResults } from './components/SearchResults';
|
|||||||
import { BrowseTab } from './components/BrowseTab';
|
import { BrowseTab } from './components/BrowseTab';
|
||||||
import { RecentTab } from './components/RecentTab';
|
import { RecentTab } from './components/RecentTab';
|
||||||
import { PinnedTab } from './components/PinnedTab';
|
import { PinnedTab } from './components/PinnedTab';
|
||||||
|
import { useTranslation } from '../../../locales';
|
||||||
|
|
||||||
export const FileBrowserWidget = () => {
|
export const FileBrowserWidget = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const fileBrowserManager = useFileBrowserWidget();
|
const fileBrowserManager = useFileBrowserWidget();
|
||||||
const { tab, isSearching } = fileBrowserManager;
|
const { tab, isSearching } = fileBrowserManager;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Widget title="File Browser">
|
<Widget title={t('fileBrowserWidget.title')}>
|
||||||
<Header fileBrowserManager={fileBrowserManager} />
|
<Header fileBrowserManager={fileBrowserManager} />
|
||||||
<div className="px-4 pb-3 max-h-72 overflow-y-auto">
|
<div className="px-4 pb-3 max-h-72 overflow-y-auto">
|
||||||
{isSearching ? (
|
{isSearching ? (
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,7 @@
|
|||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import { Breadcrumb } from '../../FileBrowserApp/components/Breadcrumb';
|
import { Breadcrumb } from '../../FileBrowserApp/components/Breadcrumb';
|
||||||
import type { UseFileBrowserWidgetType } from '../useFileBrowserWidget';
|
import type { UseFileBrowserWidgetType } from '../useFileBrowserWidget';
|
||||||
|
import { useTranslation } from '../../../../locales';
|
||||||
import { EntryRow } from './EntryRow';
|
import { EntryRow } from './EntryRow';
|
||||||
|
|
||||||
type BrowseTabProps = {
|
type BrowseTabProps = {
|
||||||
@@ -8,6 +9,7 @@ type BrowseTabProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const BrowseTab = ({ fileBrowserManager }: BrowseTabProps) => {
|
export const BrowseTab = ({ fileBrowserManager }: BrowseTabProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { browsePath, setBrowsePath, entries, loading, openFile, isPinned, togglePin } = fileBrowserManager;
|
const { browsePath, setBrowsePath, entries, loading, openFile, isPinned, togglePin } = fileBrowserManager;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -20,7 +22,7 @@ export const BrowseTab = ({ fileBrowserManager }: BrowseTabProps) => {
|
|||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : entries.length === 0 ? (
|
) : entries.length === 0 ? (
|
||||||
<p className="text-xs text-duck-dark/40 py-4 text-center">Empty directory</p>
|
<p className="text-xs text-duck-dark/40 py-4 text-center">{t('fileBrowserWidget.emptyDirectory')}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-0.5">
|
<ul className="space-y-0.5">
|
||||||
{entries.map((entry) => {
|
{entries.map((entry) => {
|
||||||
|
|||||||
+11
-7
@@ -1,10 +1,13 @@
|
|||||||
import { FolderOpen, Clock, Pin, Search, X } from 'lucide-react';
|
import { FolderOpen, Clock, Pin, Search, X } from 'lucide-react';
|
||||||
import type { Tab, UseFileBrowserWidgetType } from '../useFileBrowserWidget';
|
import type { Tab, UseFileBrowserWidgetType } from '../useFileBrowserWidget';
|
||||||
|
import type { PanelTranslationKey } from '../../../../locales';
|
||||||
|
import { useTranslation } from '../../../../locales';
|
||||||
|
|
||||||
const TABS: { key: Tab; label: string; icon: typeof FolderOpen }[] = [
|
// Module-level data, so it carries a catalog KEY and the component resolves it.
|
||||||
{ key: 'browse', label: 'Browse', icon: FolderOpen },
|
const TABS: { key: Tab; labelKey: PanelTranslationKey; icon: typeof FolderOpen }[] = [
|
||||||
{ key: 'recent', label: 'Recent', icon: Clock },
|
{ key: 'browse', labelKey: 'fileBrowserWidget.browse', icon: FolderOpen },
|
||||||
{ key: 'pinned', label: 'Pinned', icon: Pin },
|
{ key: 'recent', labelKey: 'fileBrowserWidget.recent', icon: Clock },
|
||||||
|
{ key: 'pinned', labelKey: 'fileBrowserWidget.pinned', icon: Pin },
|
||||||
];
|
];
|
||||||
|
|
||||||
type HeaderProps = {
|
type HeaderProps = {
|
||||||
@@ -12,12 +15,13 @@ type HeaderProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const Header = ({ fileBrowserManager }: HeaderProps) => {
|
export const Header = ({ fileBrowserManager }: HeaderProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { tab, setTab, isSearching, searchQuery, setSearchQuery } = fileBrowserManager;
|
const { tab, setTab, isSearching, searchQuery, setSearchQuery } = fileBrowserManager;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 px-4 pb-1">
|
<div className="flex items-center gap-2 px-4 pb-1">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{TABS.map(({ key, label, icon: Icon }) => (
|
{TABS.map(({ key, labelKey, icon: Icon }) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
onClick={() => setTab(key)}
|
onClick={() => setTab(key)}
|
||||||
@@ -28,7 +32,7 @@ export const Header = ({ fileBrowserManager }: HeaderProps) => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className="h-3.5 w-3.5" />
|
<Icon className="h-3.5 w-3.5" />
|
||||||
{label}
|
{t(labelKey)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -38,7 +42,7 @@ export const Header = ({ fileBrowserManager }: HeaderProps) => {
|
|||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(ev) => setSearchQuery(ev.target.value)}
|
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"
|
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 && (
|
{searchQuery && (
|
||||||
|
|||||||
+3
-1
@@ -1,4 +1,5 @@
|
|||||||
import type { UseFileBrowserWidgetType } from '../useFileBrowserWidget';
|
import type { UseFileBrowserWidgetType } from '../useFileBrowserWidget';
|
||||||
|
import { useTranslation } from '../../../../locales';
|
||||||
import { EntryRow } from './EntryRow';
|
import { EntryRow } from './EntryRow';
|
||||||
|
|
||||||
type PinnedTabProps = {
|
type PinnedTabProps = {
|
||||||
@@ -6,10 +7,11 @@ type PinnedTabProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PinnedTab = ({ fileBrowserManager }: PinnedTabProps) => {
|
export const PinnedTab = ({ fileBrowserManager }: PinnedTabProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { pinned, openFile, togglePin } = fileBrowserManager;
|
const { pinned, openFile, togglePin } = fileBrowserManager;
|
||||||
|
|
||||||
if (pinned.length === 0) {
|
if (pinned.length === 0) {
|
||||||
return <p className="text-xs text-duck-dark/40 py-4 text-center">No pinned files</p>;
|
return <p className="text-xs text-duck-dark/40 py-4 text-center">{t('fileBrowserWidget.noPinnedFiles')}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+3
-1
@@ -1,4 +1,5 @@
|
|||||||
import type { UseFileBrowserWidgetType } from '../useFileBrowserWidget';
|
import type { UseFileBrowserWidgetType } from '../useFileBrowserWidget';
|
||||||
|
import { useTranslation } from '../../../../locales';
|
||||||
import { EntryRow } from './EntryRow';
|
import { EntryRow } from './EntryRow';
|
||||||
|
|
||||||
type RecentTabProps = {
|
type RecentTabProps = {
|
||||||
@@ -6,10 +7,11 @@ type RecentTabProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const RecentTab = ({ fileBrowserManager }: RecentTabProps) => {
|
export const RecentTab = ({ fileBrowserManager }: RecentTabProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { recents, openFile, isPinned, togglePin } = fileBrowserManager;
|
const { recents, openFile, isPinned, togglePin } = fileBrowserManager;
|
||||||
|
|
||||||
if (recents.length === 0) {
|
if (recents.length === 0) {
|
||||||
return <p className="text-xs text-duck-dark/40 py-4 text-center">No recent files</p>;
|
return <p className="text-xs text-duck-dark/40 py-4 text-center">{t('fileBrowserWidget.noRecentFiles')}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+3
-1
@@ -1,5 +1,6 @@
|
|||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import type { UseFileBrowserWidgetType } from '../useFileBrowserWidget';
|
import type { UseFileBrowserWidgetType } from '../useFileBrowserWidget';
|
||||||
|
import { useTranslation } from '../../../../locales';
|
||||||
import { EntryRow } from './EntryRow';
|
import { EntryRow } from './EntryRow';
|
||||||
|
|
||||||
type SearchResultsProps = {
|
type SearchResultsProps = {
|
||||||
@@ -7,6 +8,7 @@ type SearchResultsProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SearchResults = ({ fileBrowserManager }: SearchResultsProps) => {
|
export const SearchResults = ({ fileBrowserManager }: SearchResultsProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { searching, searchResults, openFile, isPinned, togglePin } = fileBrowserManager;
|
const { searching, searchResults, openFile, isPinned, togglePin } = fileBrowserManager;
|
||||||
|
|
||||||
if (searching) {
|
if (searching) {
|
||||||
@@ -18,7 +20,7 @@ export const SearchResults = ({ fileBrowserManager }: SearchResultsProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (searchResults.length === 0) {
|
if (searchResults.length === 0) {
|
||||||
return <p className="text-xs text-duck-dark/40 py-4 text-center">No results</p>;
|
return <p className="text-xs text-duck-dark/40 py-4 text-center">{t('fileBrowserWidget.noResults')}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -527,6 +527,14 @@
|
|||||||
"clearSelection": "Clear selection",
|
"clearSelection": "Clear selection",
|
||||||
"selected": "selected",
|
"selected": "selected",
|
||||||
"refresh": "Refresh"
|
"refresh": "Refresh"
|
||||||
|
},
|
||||||
|
"grid": {
|
||||||
|
"empty": "This folder is empty",
|
||||||
|
"sort": "Sort:",
|
||||||
|
"name": "Name",
|
||||||
|
"type": "Type",
|
||||||
|
"size": "Size",
|
||||||
|
"date": "Date"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"taskRunner": {
|
"taskRunner": {
|
||||||
@@ -607,5 +615,38 @@
|
|||||||
"timedOut": "Timed out",
|
"timedOut": "Timed out",
|
||||||
"downloadFailed": "Download failed",
|
"downloadFailed": "Download failed",
|
||||||
"couldNotStart": "Could not start the download"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -527,6 +527,14 @@
|
|||||||
"clearSelection": "Limpar seleção",
|
"clearSelection": "Limpar seleção",
|
||||||
"selected": "selecionados",
|
"selected": "selecionados",
|
||||||
"refresh": "Atualizar"
|
"refresh": "Atualizar"
|
||||||
|
},
|
||||||
|
"grid": {
|
||||||
|
"empty": "Esta pasta está vazia",
|
||||||
|
"sort": "Ordenar:",
|
||||||
|
"name": "Nome",
|
||||||
|
"type": "Tipo",
|
||||||
|
"size": "Tamanho",
|
||||||
|
"date": "Data"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"taskRunner": {
|
"taskRunner": {
|
||||||
@@ -607,5 +615,38 @@
|
|||||||
"timedOut": "Tempo esgotado",
|
"timedOut": "Tempo esgotado",
|
||||||
"downloadFailed": "Falha ao descarregar",
|
"downloadFailed": "Falha ao descarregar",
|
||||||
"couldNotStart": "Não foi possível iniciar o descarregamento"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user