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.
*/
export const AgentIdentityBar = ({ panel }: AgentIdentityBarProps) => {
const { t } = useTranslation();
const { t, tUi } = useTranslation();
const [naming, setNaming] = useState(false);
const [name, setName] = useState('');
const [error, setError] = useState<string | null>(null);
@@ -58,7 +58,7 @@ export const AgentIdentityBar = ({ panel }: AgentIdentityBarProps) => {
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => setNaming(false)}
aria-label="Cancel"
aria-label={tUi('button.cancel')}
>
<X className="h-3 w-3" />
</Button>
@@ -103,7 +103,7 @@ export const AgentIdentityBar = ({ panel }: AgentIdentityBarProps) => {
<span className="font-medium">{panel.agent.name}</span>
<span
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
? 'no peers yet'
@@ -34,7 +34,7 @@ export type ChatSessionSelection = {
const title = (s: { title: string }) => s.title || '(untitled)';
export const ChatHeader = ({ panelId }: { panelId?: string }) => {
const { t } = useTranslation();
const { t, tUi } = useTranslation();
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"
/>
<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" />
</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" />
</button>
</form>
@@ -138,11 +143,11 @@ export const ChatHeader = ({ panelId }: { panelId?: string }) => {
<Pencil className="h-3 w-3" />
</button>
<button
title="Delete"
title={t('common.delete')}
onClick={(ev) => {
ev.preventDefault();
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
// new conversation rather than showing a transcript that no longer exists.
if (s.id === sessionId) setSessionId(null);
@@ -68,7 +68,12 @@ export function AttachButton({
const fence = text.includes('```') ? '````' : '```';
onAttachText(`${fence} ${file.name}\n${text.replace(/\s+$/, '')}\n${fence}`);
} 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,
recordPaths,
}: BackgroundJobDialogProps) => {
const { t } = useTranslation();
const { t, tUi } = useTranslation();
const [mode, setMode] = useState<'command' | 'prompt'>('command');
const [body, setBody] = useState('');
const [watching, setWatching] = useState(false);
@@ -220,7 +220,7 @@ export const BackgroundJobDialog = ({
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
{tUi('button.cancel')}
</Button>
<Button type="submit" disabled={!canSubmit}>
{t('chat.job.start')}
@@ -316,7 +316,7 @@ const TaskChip = ({ task, active, pinned, onClick, onTogglePin, onDismiss, onSto
type="button"
onClick={onStop}
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"
>
<Square className="h-3 w-3" />
@@ -327,7 +327,7 @@ const TaskChip = ({ task, active, pinned, onClick, onTogglePin, onDismiss, onSto
type="button"
onClick={onDismiss}
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"
>
<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 };
export const ClaudeLoginDialog = ({ open, onOpenChange }: Props) => {
const { t } = useTranslation();
const { t, tUi } = useTranslation();
const client = useClient();
const [started, setStarted] = useState<Started | null>(null);
const [code, setCode] = useState('');
@@ -162,7 +162,7 @@ export const ClaudeLoginDialog = ({ open, onOpenChange }: Props) => {
onClick={() => onOpenChange(false)}
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground"
>
Cancel
{tUi('button.cancel')}
</button>
<button
type="button"
@@ -179,7 +179,7 @@ export const CodeSurface = ({ code, lang = '', startLine = 1, maxLines, numbered
onClick={() => setExpanded(!expanded)}
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>
)}
</div>
@@ -86,7 +86,7 @@ const CollapsibleBlock = ({ label, content }: { label: string; content: string }
type TtsState = 'idle' | 'loading' | 'playing';
const ReadAloudButton = ({ id, text }: { id: string; text: string }) => {
const { t } = useTranslation();
const { t, tUi } = useTranslation();
const [state, setState] = useState<TtsState>('idle');
const audioRef = useRef<HTMLAudioElement | null>(null);
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
// service being down, which is worth saying out loud.
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.
*/
const TaskActivity = ({ message }: { message: Extract<ChatMessage, { role: 'task' }> }) => {
const { t, tUi } = useTranslation();
const { status, summary, description, taskType } = message;
const Icon = status === 'completed' ? Check : status === 'failed' ? X : status === 'stopped' ? CircleSlash : Clock;
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>}
</div>
<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>
@@ -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.
*/
const CompactActivity = ({ message }: { message: Extract<ChatMessage, { role: 'compact' }> }) => {
const { t, tUi } = useTranslation();
const { done, trigger, preTokens, durationMs } = message;
const startedAt = useRef(Date.now());
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" />
<span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
<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>}
</span>
<div className="h-px flex-1 bg-border" />
@@ -502,7 +508,7 @@ const PlanCard = ({
message: Extract<ChatMessage, { role: 'plan' }>;
onDecide?: (requestId: string, approve: boolean, feedback?: string) => void;
}) => {
const { t } = useTranslation();
const { t, tUi } = useTranslation();
const [feedback, setFeedback] = useState('');
const [rejecting, setRejecting] = useState(false);
const decided = message.approved !== undefined;
@@ -554,7 +560,7 @@ const PlanCard = ({
onClick={() => setRejecting(false)}
className="cursor-pointer rounded px-2 py-1 text-xs text-muted-foreground hover:text-foreground"
>
Cancel
{tUi('button.cancel')}
</button>
)}
<button
@@ -308,6 +308,7 @@ export const ToolActivity = ({ message, defaultOpen }: ToolActivityProps) => {
const DIFF_MAX_LINES = 40;
const DiffView = ({ lines }: { lines: DiffLine[] }) => {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const hidden = lines.length - 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)}
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>
)}
</>
@@ -118,7 +118,12 @@ const Models = ({ account }: { account: UsageAccount }) => {
<div
key={model.name}
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
track would leave no room for either. */}
@@ -287,8 +292,10 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
title: a.current
? `${a.emailAccount} — signed in`
: a.usable
? `Switch to ${a.emailAccount}${a.orgName ? ` (${a.orgName})` : ''}`
: `${a.emailAccount} — sign-in expired, run /login`,
? a.orgName
? 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) => ({
key: `archived:${a.email}`,
@@ -2,12 +2,14 @@ import { useState } from 'react';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
import type { Attachment } from './types';
import { useTranslation } from '../../locales';
type UseAttachmentsParams = {
sessionId?: string | null;
};
export function useAttachments(params?: UseAttachmentsParams) {
const { t } = useTranslation();
const client = useClient();
const [attachments, setAttachments] = useState<Attachment[]>([]);
@@ -33,7 +35,7 @@ export function useAttachments(params?: UseAttachmentsParams) {
);
} catch {
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 {
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 { toast } from 'sonner';
import { useTranslation } from '../../locales';
export function useAudioRecording(onTranscription: (text: string) => void) {
const { t } = useTranslation();
const [recording, setRecording] = useState(false);
const [transcribing, setTranscribing] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
@@ -16,7 +18,7 @@ export function useAudioRecording(onTranscription: (text: string) => void) {
try {
if (recorder.state === 'inactive') {
recorder.stream.getTracks().forEach((t) => t.stop());
recorder.stream.getTracks().forEach((track) => track.stop());
return;
}
@@ -30,10 +32,10 @@ export function useAudioRecording(onTranscription: (text: string) => void) {
recorder.stop();
});
recorder.stream.getTracks().forEach((t) => t.stop());
recorder.stream.getTracks().forEach((track) => track.stop());
if (blob.size === 0) {
toast.error('No audio was captured');
toast.error(t('chat.composer.noAudio'));
return;
}
@@ -61,7 +63,7 @@ export function useAudioRecording(onTranscription: (text: string) => void) {
setTranscribing(false);
}
} catch (err) {
recorder.stream?.getTracks().forEach((t) => t.stop());
recorder.stream?.getTracks().forEach((track) => track.stop());
chunksRef.current = [];
toast.error(err instanceof Error ? err.message : 'Recording failed');
}
@@ -94,7 +96,7 @@ export function useAudioRecording(onTranscription: (text: string) => void) {
const recorder = mediaRecorderRef.current;
if (!recorder) return;
if (recorder.state !== 'inactive') recorder.stop();
recorder.stream?.getTracks().forEach((t) => t.stop());
recorder.stream?.getTracks().forEach((track) => track.stop());
mediaRecorderRef.current = null;
chunksRef.current = [];
},
@@ -361,7 +361,9 @@
"byModel": "Tokens by model",
"title": "Usage and limits",
"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": {
"image": "Image",
@@ -361,7 +361,9 @@
"byModel": "Tokens por modelo",
"title": "Consumo e limites",
"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": {
"image": "Imagem",