chat panel reaches zero convertible (203 -> 181)

The remaining interpolated strings and the shared Cancel/Save/Delete labels. What is
left in Chat is three thrown Errors, which the never-translate category already
excludes.

useAudioRecording used `t` as its media-track loop variable in four places, which the
translation function then shadowed. Fourth panel with that collision.
This commit is contained in:
2026-08-23 15:47:05 +00:00
parent 68a0a8e326
commit 4466ac93c3
14 changed files with 67 additions and 35 deletions
@@ -16,7 +16,7 @@ type AgentIdentityBarProps = { panel: UseAgentPanelType };
* the two apart is to notice that the conversation did not reset. * the two apart is to notice that the conversation did not reset.
*/ */
export const AgentIdentityBar = ({ panel }: AgentIdentityBarProps) => { export const AgentIdentityBar = ({ panel }: AgentIdentityBarProps) => {
const { t } = useTranslation(); const { t, tUi } = useTranslation();
const [naming, setNaming] = useState(false); const [naming, setNaming] = useState(false);
const [name, setName] = useState(''); const [name, setName] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -58,7 +58,7 @@ export const AgentIdentityBar = ({ panel }: AgentIdentityBarProps) => {
variant="ghost" variant="ghost"
className="h-6 w-6 p-0" className="h-6 w-6 p-0"
onClick={() => setNaming(false)} onClick={() => setNaming(false)}
aria-label="Cancel" aria-label={tUi('button.cancel')}
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</Button> </Button>
@@ -103,7 +103,7 @@ export const AgentIdentityBar = ({ panel }: AgentIdentityBarProps) => {
<span className="font-medium">{panel.agent.name}</span> <span className="font-medium">{panel.agent.name}</span>
<span <span
className="text-muted-foreground flex-1 truncate" className="text-muted-foreground flex-1 truncate"
title={peers.length ? `Peers: ${peers.map((p) => p.name).join(', ')}` : undefined} title={peers.length ? t('chat.agent.peers', { names: peers.map((p) => p.name).join(', ') }) : undefined}
> >
{peers.length === 0 {peers.length === 0
? 'no peers yet' ? 'no peers yet'
@@ -34,7 +34,7 @@ export type ChatSessionSelection = {
const title = (s: { title: string }) => s.title || '(untitled)'; const title = (s: { title: string }) => s.title || '(untitled)';
export const ChatHeader = ({ panelId }: { panelId?: string }) => { export const ChatHeader = ({ panelId }: { panelId?: string }) => {
const { t } = useTranslation(); const { t, tUi } = useTranslation();
const { workspace, cwd } = useWorkspace(); const { workspace, cwd } = useWorkspace();
/** /**
@@ -109,10 +109,15 @@ export const ChatHeader = ({ panelId }: { panelId?: string }) => {
}} }}
className="min-w-0 flex-1 rounded border border-duck-dark/20 bg-transparent px-1.5 py-0.5 text-xs" className="min-w-0 flex-1 rounded border border-duck-dark/20 bg-transparent px-1.5 py-0.5 text-xs"
/> />
<button type="submit" className="p-0.5 cursor-pointer" title="Save"> <button type="submit" className="p-0.5 cursor-pointer" title={t('common.save')}>
<Check className="h-3.5 w-3.5" /> <Check className="h-3.5 w-3.5" />
</button> </button>
<button type="button" onClick={() => setRenaming(null)} className="p-0.5 cursor-pointer" title="Cancel"> <button
type="button"
onClick={() => setRenaming(null)}
className="p-0.5 cursor-pointer"
title={tUi('button.cancel')}
>
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</button> </button>
</form> </form>
@@ -138,11 +143,11 @@ export const ChatHeader = ({ panelId }: { panelId?: string }) => {
<Pencil className="h-3 w-3" /> <Pencil className="h-3 w-3" />
</button> </button>
<button <button
title="Delete" title={t('common.delete')}
onClick={(ev) => { onClick={(ev) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
if (!confirm(`Delete "${title(s)}"?`)) return; if (!confirm(t('chat.deleteQ', { title: title(s) }))) return;
// Deleting the one on screen leaves this panel pointing at nothing, so it starts a // Deleting the one on screen leaves this panel pointing at nothing, so it starts a
// new conversation rather than showing a transcript that no longer exists. // new conversation rather than showing a transcript that no longer exists.
if (s.id === sessionId) setSessionId(null); if (s.id === sessionId) setSessionId(null);
@@ -68,7 +68,12 @@ export function AttachButton({
const fence = text.includes('```') ? '````' : '```'; const fence = text.includes('```') ? '````' : '```';
onAttachText(`${fence} ${file.name}\n${text.replace(/\s+$/, '')}\n${fence}`); onAttachText(`${fence} ${file.name}\n${text.replace(/\s+$/, '')}\n${fence}`);
} catch (err) { } catch (err) {
toast.error(`Could not read ${file.name}: ${err instanceof Error ? err.message : 'unknown error'}`); toast.error(
t('chat.attach.readFailed', {
name: file.name,
reason: err instanceof Error ? err.message : t('chat.attach.unknownError'),
}),
);
} }
}; };
@@ -65,7 +65,7 @@ export const BackgroundJobDialog = ({
isCommander = false, isCommander = false,
recordPaths, recordPaths,
}: BackgroundJobDialogProps) => { }: BackgroundJobDialogProps) => {
const { t } = useTranslation(); const { t, tUi } = useTranslation();
const [mode, setMode] = useState<'command' | 'prompt'>('command'); const [mode, setMode] = useState<'command' | 'prompt'>('command');
const [body, setBody] = useState(''); const [body, setBody] = useState('');
const [watching, setWatching] = useState(false); const [watching, setWatching] = useState(false);
@@ -220,7 +220,7 @@ export const BackgroundJobDialog = ({
<DialogFooter> <DialogFooter>
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}> <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
Cancel {tUi('button.cancel')}
</Button> </Button>
<Button type="submit" disabled={!canSubmit}> <Button type="submit" disabled={!canSubmit}>
{t('chat.job.start')} {t('chat.job.start')}
@@ -316,7 +316,7 @@ const TaskChip = ({ task, active, pinned, onClick, onTogglePin, onDismiss, onSto
type="button" type="button"
onClick={onStop} onClick={onStop}
title={t('chat.tasks.stopJob')} title={t('chat.tasks.stopJob')}
aria-label={`Stop ${label}`} aria-label={t('chat.tasks.stopNamed', { label })}
className="cursor-pointer py-1 pl-0.5 pr-2 text-muted-foreground/50 transition-colors hover:text-destructive" className="cursor-pointer py-1 pl-0.5 pr-2 text-muted-foreground/50 transition-colors hover:text-destructive"
> >
<Square className="h-3 w-3" /> <Square className="h-3 w-3" />
@@ -327,7 +327,7 @@ const TaskChip = ({ task, active, pinned, onClick, onTogglePin, onDismiss, onSto
type="button" type="button"
onClick={onDismiss} onClick={onDismiss}
title={t('chat.tasks.dismiss')} title={t('chat.tasks.dismiss')}
aria-label={`Dismiss ${label}`} aria-label={t('chat.tasks.dismissNamed', { label })}
className="cursor-pointer rounded-full py-1 pl-0.5 pr-2 text-muted-foreground/50 transition-colors hover:text-foreground" className="cursor-pointer rounded-full py-1 pl-0.5 pr-2 text-muted-foreground/50 transition-colors hover:text-foreground"
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
@@ -25,7 +25,7 @@ type Started = { id: string; url: string; expiresInMs: number };
type Props = { open: boolean; onOpenChange: (open: boolean) => void }; type Props = { open: boolean; onOpenChange: (open: boolean) => void };
export const ClaudeLoginDialog = ({ open, onOpenChange }: Props) => { export const ClaudeLoginDialog = ({ open, onOpenChange }: Props) => {
const { t } = useTranslation(); const { t, tUi } = useTranslation();
const client = useClient(); const client = useClient();
const [started, setStarted] = useState<Started | null>(null); const [started, setStarted] = useState<Started | null>(null);
const [code, setCode] = useState(''); const [code, setCode] = useState('');
@@ -162,7 +162,7 @@ export const ClaudeLoginDialog = ({ open, onOpenChange }: Props) => {
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground" className="rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground"
> >
Cancel {tUi('button.cancel')}
</button> </button>
<button <button
type="button" type="button"
@@ -179,7 +179,7 @@ export const CodeSurface = ({ code, lang = '', startLine = 1, maxLines, numbered
onClick={() => setExpanded(!expanded)} onClick={() => setExpanded(!expanded)}
className="mt-1 cursor-pointer text-xs text-duck-teal hover:underline" className="mt-1 cursor-pointer text-xs text-duck-teal hover:underline"
> >
{expanded ? 'Show less' : `Show more (${hidden} more lines)`} {expanded ? 'Show less' : t('chat.message.showMore', { count: hidden })}
</button> </button>
)} )}
</div> </div>
@@ -86,7 +86,7 @@ const CollapsibleBlock = ({ label, content }: { label: string; content: string }
type TtsState = 'idle' | 'loading' | 'playing'; type TtsState = 'idle' | 'loading' | 'playing';
const ReadAloudButton = ({ id, text }: { id: string; text: string }) => { const ReadAloudButton = ({ id, text }: { id: string; text: string }) => {
const { t } = useTranslation(); const { t, tUi } = useTranslation();
const [state, setState] = useState<TtsState>('idle'); const [state, setState] = useState<TtsState>('idle');
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
const api = useFilesAPI('user-data'); const api = useFilesAPI('user-data');
@@ -122,7 +122,7 @@ const ReadAloudButton = ({ id, text }: { id: string; text: string }) => {
// the icon comes back, the button looks broken rather than failed. Most often this is the TTS // the icon comes back, the button looks broken rather than failed. Most often this is the TTS
// service being down, which is worth saying out loud. // service being down, which is worth saying out loud.
setState('idle'); setState('idle');
toast.error(`Could not read this message aloud: ${errorText(err)}`); toast.error(t('chat.message.readAloudFailed', { reason: errorText(err) }));
} }
}; };
@@ -343,6 +343,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
* both sides discarded the taskId that relates them. * both sides discarded the taskId that relates them.
*/ */
const TaskActivity = ({ message }: { message: Extract<ChatMessage, { role: 'task' }> }) => { const TaskActivity = ({ message }: { message: Extract<ChatMessage, { role: 'task' }> }) => {
const { t, tUi } = useTranslation();
const { status, summary, description, taskType } = message; const { status, summary, description, taskType } = message;
const Icon = status === 'completed' ? Check : status === 'failed' ? X : status === 'stopped' ? CircleSlash : Clock; const Icon = status === 'completed' ? Check : status === 'failed' ? X : status === 'stopped' ? CircleSlash : Clock;
const tone: Tone = const tone: Tone =
@@ -357,7 +358,7 @@ const TaskActivity = ({ message }: { message: Extract<ChatMessage, { role: 'task
{taskType && <span className="ml-2 text-xs text-muted-foreground">{taskType}</span>} {taskType && <span className="ml-2 text-xs text-muted-foreground">{taskType}</span>}
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{status ? summary || `Task ${status}.` : 'Running in the background…'} {status ? summary || t('chat.tasks.status', { status }) : 'Running in the background…'}
</div> </div>
</div> </div>
</div> </div>
@@ -384,6 +385,7 @@ const formatTokens = (n: number): string => (n >= 1000 ? `${Math.round(n / 1000)
* the sidecar measured — which is the one a replay in another tab gets. * the sidecar measured — which is the one a replay in another tab gets.
*/ */
const CompactActivity = ({ message }: { message: Extract<ChatMessage, { role: 'compact' }> }) => { const CompactActivity = ({ message }: { message: Extract<ChatMessage, { role: 'compact' }> }) => {
const { t, tUi } = useTranslation();
const { done, trigger, preTokens, durationMs } = message; const { done, trigger, preTokens, durationMs } = message;
const startedAt = useRef(Date.now()); const startedAt = useRef(Date.now());
const [elapsed, setElapsed] = useState(0); const [elapsed, setElapsed] = useState(0);
@@ -405,7 +407,11 @@ const CompactActivity = ({ message }: { message: Extract<ChatMessage, { role: 'c
<div className="h-px flex-1 bg-border" /> <div className="h-px flex-1 bg-border" />
<span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase"> <span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
<FoldVertical className={`h-3 w-3 ${done ? '' : 'animate-pulse'}`} /> <FoldVertical className={`h-3 w-3 ${done ? '' : 'animate-pulse'}`} />
{done ? `Context compacted${trigger === 'manual' ? '' : ' automatically'}` : 'Compacting the conversation…'} {done
? trigger === 'manual'
? t('chat.message.compacted')
: t('chat.message.compactedAuto')
: 'Compacting the conversation…'}
{detail && <span className="normal-case opacity-70 tabular-nums">· {detail}</span>} {detail && <span className="normal-case opacity-70 tabular-nums">· {detail}</span>}
</span> </span>
<div className="h-px flex-1 bg-border" /> <div className="h-px flex-1 bg-border" />
@@ -502,7 +508,7 @@ const PlanCard = ({
message: Extract<ChatMessage, { role: 'plan' }>; message: Extract<ChatMessage, { role: 'plan' }>;
onDecide?: (requestId: string, approve: boolean, feedback?: string) => void; onDecide?: (requestId: string, approve: boolean, feedback?: string) => void;
}) => { }) => {
const { t } = useTranslation(); const { t, tUi } = useTranslation();
const [feedback, setFeedback] = useState(''); const [feedback, setFeedback] = useState('');
const [rejecting, setRejecting] = useState(false); const [rejecting, setRejecting] = useState(false);
const decided = message.approved !== undefined; const decided = message.approved !== undefined;
@@ -554,7 +560,7 @@ const PlanCard = ({
onClick={() => setRejecting(false)} onClick={() => setRejecting(false)}
className="cursor-pointer rounded px-2 py-1 text-xs text-muted-foreground hover:text-foreground" className="cursor-pointer rounded px-2 py-1 text-xs text-muted-foreground hover:text-foreground"
> >
Cancel {tUi('button.cancel')}
</button> </button>
)} )}
<button <button
@@ -308,6 +308,7 @@ export const ToolActivity = ({ message, defaultOpen }: ToolActivityProps) => {
const DIFF_MAX_LINES = 40; const DIFF_MAX_LINES = 40;
const DiffView = ({ lines }: { lines: DiffLine[] }) => { const DiffView = ({ lines }: { lines: DiffLine[] }) => {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const hidden = lines.length - DIFF_MAX_LINES; const hidden = lines.length - DIFF_MAX_LINES;
const shown = expanded ? lines : lines.slice(0, DIFF_MAX_LINES); const shown = expanded ? lines : lines.slice(0, DIFF_MAX_LINES);
@@ -353,7 +354,7 @@ const DiffView = ({ lines }: { lines: DiffLine[] }) => {
onClick={() => setExpanded(!expanded)} onClick={() => setExpanded(!expanded)}
className="mt-1 cursor-pointer text-xs text-duck-teal hover:underline" className="mt-1 cursor-pointer text-xs text-duck-teal hover:underline"
> >
{expanded ? 'Show less' : `Show more (${hidden} more lines)`} {expanded ? 'Show less' : t('chat.message.showMore', { count: hidden })}
</button> </button>
)} )}
</> </>
@@ -118,7 +118,12 @@ const Models = ({ account }: { account: UsageAccount }) => {
<div <div
key={model.name} key={model.name}
className="relative flex items-center justify-between gap-2 overflow-hidden rounded-md px-2 py-1.5" className="relative flex items-center justify-between gap-2 overflow-hidden rounded-md px-2 py-1.5"
title={`In ${formatTokens(model.input)} · out ${formatTokens(model.output)} · cache read ${formatTokens(model.cacheRead)} · cache write ${formatTokens(model.cacheWrite)}`} title={t('chat.usage.modelBreakdown', {
input: formatTokens(model.input),
output: formatTokens(model.output),
cacheRead: formatTokens(model.cacheRead),
cacheWrite: formatTokens(model.cacheWrite),
})}
> >
{/* The bar sits BEHIND the row rather than beside it — the model names are long and a separate {/* The bar sits BEHIND the row rather than beside it — the model names are long and a separate
track would leave no room for either. */} track would leave no room for either. */}
@@ -287,8 +292,10 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
title: a.current title: a.current
? `${a.emailAccount} — signed in` ? `${a.emailAccount} — signed in`
: a.usable : a.usable
? `Switch to ${a.emailAccount}${a.orgName ? ` (${a.orgName})` : ''}` ? a.orgName
: `${a.emailAccount} — sign-in expired, run /login`, ? t('chat.usage.switchToOrg', { account: a.emailAccount, org: a.orgName })
: t('chat.usage.switchTo', { account: a.emailAccount })
: t('chat.usage.signInExpired', { account: a.emailAccount }),
})), })),
...archived.map((a) => ({ ...archived.map((a) => ({
key: `archived:${a.email}`, key: `archived:${a.email}`,
@@ -2,12 +2,14 @@ import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import type { Attachment } from './types'; import type { Attachment } from './types';
import { useTranslation } from '../../locales';
type UseAttachmentsParams = { type UseAttachmentsParams = {
sessionId?: string | null; sessionId?: string | null;
}; };
export function useAttachments(params?: UseAttachmentsParams) { export function useAttachments(params?: UseAttachmentsParams) {
const { t } = useTranslation();
const client = useClient(); const client = useClient();
const [attachments, setAttachments] = useState<Attachment[]>([]); const [attachments, setAttachments] = useState<Attachment[]>([]);
@@ -33,7 +35,7 @@ export function useAttachments(params?: UseAttachmentsParams) {
); );
} catch { } catch {
setAttachments((prev) => prev.filter((_, i) => i !== idx)); setAttachments((prev) => prev.filter((_, i) => i !== idx));
toast.error('Failed to scrape webpage'); toast.error(t('chat.attach.scrapeFailed'));
} }
}; };
@@ -58,7 +60,7 @@ export function useAttachments(params?: UseAttachmentsParams) {
); );
} catch { } catch {
setAttachments((prev) => prev.filter((_, i) => i !== idx)); setAttachments((prev) => prev.filter((_, i) => i !== idx));
toast.error('Failed to upload image'); toast.error(t('chat.attach.uploadFailed'));
} }
}; };
@@ -1,7 +1,9 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from '../../locales';
export function useAudioRecording(onTranscription: (text: string) => void) { export function useAudioRecording(onTranscription: (text: string) => void) {
const { t } = useTranslation();
const [recording, setRecording] = useState(false); const [recording, setRecording] = useState(false);
const [transcribing, setTranscribing] = useState(false); const [transcribing, setTranscribing] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null); const mediaRecorderRef = useRef<MediaRecorder | null>(null);
@@ -16,7 +18,7 @@ export function useAudioRecording(onTranscription: (text: string) => void) {
try { try {
if (recorder.state === 'inactive') { if (recorder.state === 'inactive') {
recorder.stream.getTracks().forEach((t) => t.stop()); recorder.stream.getTracks().forEach((track) => track.stop());
return; return;
} }
@@ -30,10 +32,10 @@ export function useAudioRecording(onTranscription: (text: string) => void) {
recorder.stop(); recorder.stop();
}); });
recorder.stream.getTracks().forEach((t) => t.stop()); recorder.stream.getTracks().forEach((track) => track.stop());
if (blob.size === 0) { if (blob.size === 0) {
toast.error('No audio was captured'); toast.error(t('chat.composer.noAudio'));
return; return;
} }
@@ -61,7 +63,7 @@ export function useAudioRecording(onTranscription: (text: string) => void) {
setTranscribing(false); setTranscribing(false);
} }
} catch (err) { } catch (err) {
recorder.stream?.getTracks().forEach((t) => t.stop()); recorder.stream?.getTracks().forEach((track) => track.stop());
chunksRef.current = []; chunksRef.current = [];
toast.error(err instanceof Error ? err.message : 'Recording failed'); toast.error(err instanceof Error ? err.message : 'Recording failed');
} }
@@ -94,7 +96,7 @@ export function useAudioRecording(onTranscription: (text: string) => void) {
const recorder = mediaRecorderRef.current; const recorder = mediaRecorderRef.current;
if (!recorder) return; if (!recorder) return;
if (recorder.state !== 'inactive') recorder.stop(); if (recorder.state !== 'inactive') recorder.stop();
recorder.stream?.getTracks().forEach((t) => t.stop()); recorder.stream?.getTracks().forEach((track) => track.stop());
mediaRecorderRef.current = null; mediaRecorderRef.current = null;
chunksRef.current = []; chunksRef.current = [];
}, },
@@ -361,7 +361,9 @@
"byModel": "Tokens by model", "byModel": "Tokens by model",
"title": "Usage and limits", "title": "Usage and limits",
"modelBreakdown": "In {{input}} · out {{output}} · cache read {{cacheRead}} · cache write {{cacheWrite}}", "modelBreakdown": "In {{input}} · out {{output}} · cache read {{cacheRead}} · cache write {{cacheWrite}}",
"switchTo": "Switch to {{account}}" "switchTo": "Switch to {{account}}",
"switchToOrg": "Switch to {{account}} ({{org}})",
"signInExpired": "{{account}} — sign-in expired, run /login"
}, },
"attach": { "attach": {
"image": "Image", "image": "Image",
@@ -361,7 +361,9 @@
"byModel": "Tokens por modelo", "byModel": "Tokens por modelo",
"title": "Consumo e limites", "title": "Consumo e limites",
"modelBreakdown": "Entrada {{input}} · saída {{output}} · leitura de cache {{cacheRead}} · escrita de cache {{cacheWrite}}", "modelBreakdown": "Entrada {{input}} · saída {{output}} · leitura de cache {{cacheRead}} · escrita de cache {{cacheWrite}}",
"switchTo": "Mudar para {{account}}" "switchTo": "Mudar para {{account}}",
"switchToOrg": "Mudar para {{account}} ({{org}})",
"signInExpired": "{{account}} — sessão expirada, execute /login"
}, },
"attach": { "attach": {
"image": "Imagem", "image": "Imagem",