chat panel keyed, first pass (280 -> 203)
The bulk of the chat UI: composer, background-job dialog, task list, usage popover,
attachments, the Claude sign-in flow and the message bubbles.
background-job-prompt.ts is exempt in full — the module builds the brief the MODEL
executes, and translating it would change what the agent does. Two marked lines,
both with the reason.
QuestionActivity.test.tsx now renders inside an I18nProvider with the real panel
catalogue. It asserted on 'Other...' and would otherwise have seen the dot-path;
wrapping keeps it asserting the English a person actually reads. Same fix the
DataTable test needed in phase 1.
Scanner, two counting bugs, each delta verified:
- `new Error(`…`)` with a template literal fell through to 'template str' and was
counted as convertible — the opposite of true. Now it lands in thrown errors.
- and it was then matched by BOTH patterns, so it was counted twice while staying
convertible; the template-string pattern now declines what a thrown Error owns.
This commit is contained in:
@@ -74,9 +74,20 @@ const PATTERNS: { kind: string; rx: RegExp; neverTranslate?: true }[] = [
|
||||
),
|
||||
},
|
||||
// Thrown Errors are for whoever is reading a terminal at 4am. They stay in one language.
|
||||
{ kind: 'thrown errors', rx: new RegExp(String.raw`new Error\(\s*` + Q, 'g'), neverTranslate: true },
|
||||
//
|
||||
// Matches a template literal as well as a quoted one: `new Error(\`Whisper returned ${res.status}\`)`
|
||||
// was falling through to `template str` and so counted as convertible, which is the opposite of true.
|
||||
{
|
||||
kind: 'thrown errors',
|
||||
rx: new RegExp(String.raw`new Error\(\s*(?:` + Q + String.raw`|\`([^\`]{2,120})\`)`, 'g'),
|
||||
neverTranslate: true,
|
||||
},
|
||||
// `Failed to create ${kind}` — needs interpolation, so it costs more to convert.
|
||||
{ kind: 'template str', rx: /`([A-Z][^`\n]{4,100}\$\{[^`]*)`/g },
|
||||
//
|
||||
// The lookbehind hands a thrown Error to the category above instead of counting it here as well: a
|
||||
// string matched by two patterns is counted twice, and it stayed convertible, so the area could never
|
||||
// reach zero however it was treated.
|
||||
{ kind: 'template str', rx: /(?<!new Error\()`([A-Z][^`\n]{4,100}\$\{[^`]*)`/g },
|
||||
];
|
||||
|
||||
/** Filters out ids, class names, paths, mime types and SCREAMING_CONSTANTS. */
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Users, X } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { UseAgentPanelType } from './useAgentPanel';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
type AgentIdentityBarProps = { panel: UseAgentPanelType };
|
||||
|
||||
@@ -15,6 +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 [naming, setNaming] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -69,9 +71,9 @@ export const AgentIdentityBar = ({ panel }: AgentIdentityBarProps) => {
|
||||
return (
|
||||
<div className="text-muted-foreground flex items-center gap-1.5 border-b px-2 py-1.5 text-xs">
|
||||
<Users className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate">This panel's agent no longer exists.</span>
|
||||
<span className="flex-1 truncate">{t('chat.agent.gone')}</span>
|
||||
<Button size="sm" variant="ghost" className="h-6 px-2 text-xs" onClick={panel.release}>
|
||||
Forget it
|
||||
{t('chat.agent.forget')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -87,7 +89,7 @@ export const AgentIdentityBar = ({ panel }: AgentIdentityBarProps) => {
|
||||
onClick={() => setNaming(true)}
|
||||
>
|
||||
<Users className="mr-1 h-3.5 w-3.5" />
|
||||
Name this agent
|
||||
{t('chat.agent.name')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useChatPwds, useClaudeSessions } from 'state/useClaudeSessions';
|
||||
import { useWorkspace, cwdToPath } from '../../components/Workspace';
|
||||
import { usePanelSession } from './use-panel-session';
|
||||
import type { ChatMessage } from './types';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
export type ChatSessionSelection = {
|
||||
sessionId: string | null;
|
||||
@@ -33,6 +34,7 @@ export type ChatSessionSelection = {
|
||||
const title = (s: { title: string }) => s.title || '(untitled)';
|
||||
|
||||
export const ChatHeader = ({ panelId }: { panelId?: string }) => {
|
||||
const { t } = useTranslation();
|
||||
const { workspace, cwd } = useWorkspace();
|
||||
|
||||
/**
|
||||
@@ -57,7 +59,7 @@ export const ChatHeader = ({ panelId }: { panelId?: string }) => {
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
const current = sessions.find((s) => s.id === sessionId);
|
||||
const label = current ? title(current) : sessionId ? 'Conversation' : 'New chat';
|
||||
const label = current ? title(current) : sessionId ? 'Conversation' : t('chat.newChat');
|
||||
|
||||
const commitRename = async (id: string) => {
|
||||
const next = draft.trim();
|
||||
@@ -80,7 +82,7 @@ export const ChatHeader = ({ panelId }: { panelId?: string }) => {
|
||||
names it, because only the harness can decide that. */}
|
||||
<DropdownMenuItem onClick={() => setSessionId(null)} className="cursor-pointer">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New chat
|
||||
{t('chat.newChat')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{sessions.length > 0 && <DropdownMenuSeparator />}
|
||||
@@ -124,7 +126,7 @@ export const ChatHeader = ({ panelId }: { panelId?: string }) => {
|
||||
{/* Siblings of the label, never nested inside it — these mutate, they do not select. */}
|
||||
<span className="ml-2 flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
title="Rename"
|
||||
title={t('chat.rename')}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ModelSelector } from '../components/ModelSelector';
|
||||
import { AttachmentList } from '../components/AttachmentList';
|
||||
import { AttachButton } from '../components/AttachButton';
|
||||
import { WebpageDialog } from '../components/WebpageDialog';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
type ChatLauncherProps = {
|
||||
availableModels: ModelOption[];
|
||||
@@ -26,8 +27,10 @@ export function ChatLauncher({
|
||||
selectedModel,
|
||||
onModelChange,
|
||||
onSubmit,
|
||||
placeholder = 'What do you want to work on now?',
|
||||
placeholder,
|
||||
}: ChatLauncherProps) {
|
||||
const { t } = useTranslation();
|
||||
const composerPlaceholder = placeholder ?? t('chat.composerPlaceholder');
|
||||
const { attachments, attachWebpage, attachImage, removeAttachment, clearAttachments, processAttachments } =
|
||||
useAttachments();
|
||||
const [input, setInput] = useState('');
|
||||
@@ -95,7 +98,7 @@ export function ChatLauncher({
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
placeholder={composerPlaceholder}
|
||||
rows={1}
|
||||
className="flex-1 resize-none bg-transparent px-2 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none text-lg"
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useAgentPanel, type UseAgentPanelType } from './useAgentPanel';
|
||||
import { usePanelSession } from './use-panel-session';
|
||||
import { useChatPwds, useClaudeSessions } from 'state/useClaudeSessions';
|
||||
import { cwdToPath } from '../../components/Workspace';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
/** A fetched transcript, ready to open on. */
|
||||
type ResumeState = {
|
||||
@@ -80,6 +81,7 @@ const ChatPanelInner = ({
|
||||
resume,
|
||||
panelId,
|
||||
}: ChatPanelInnerProps) => {
|
||||
const { t } = useTranslation();
|
||||
/**
|
||||
* Resuming needs the TRANSCRIPT, not just the id.
|
||||
*
|
||||
@@ -111,7 +113,7 @@ const ChatPanelInner = ({
|
||||
`claudeSessionId`, NOT `sessionId`. The panel's stored id is looked up in the sessions LIST — see
|
||||
`ChatHeader`'s `sessions.find((s) => s.id === sessionId)` — and that list is keyed on Claude's
|
||||
transcript id. Recording officer's own session key here put two id spaces on either side of one
|
||||
lookup: it never matched, so a new chat's header read "New chat" for the life of the conversation and
|
||||
lookup: it never matched, so a new chat's header read t('chat.newChat') for the life of the conversation and
|
||||
offered no rename, because rename is a per-row action on a list the session was not found in.
|
||||
Selecting the conversation from the list fixed it, which is why navigating away and back appeared to
|
||||
be the cure.
|
||||
|
||||
@@ -3,13 +3,14 @@ import type { UseChatType } from '../../../hooks/useChat';
|
||||
import { useEmbeddableChat } from './useEmbeddableChat';
|
||||
import { MessageList } from '../components/MessageList';
|
||||
import { InputArea } from '../components/InputArea';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
type EmbeddableChatProps = {
|
||||
sessionId?: string;
|
||||
/**
|
||||
* What the transcript shows before there is anything in it. `null` shows nothing.
|
||||
*
|
||||
* The default, "Send a message to start", is true on the chat screen and wrong wherever something else
|
||||
* The default, t('chat.composer.startHint'), is true on the chat screen and wrong wherever something else
|
||||
* already explains what to do — a new commander has a setup card and a Start button above it, so the
|
||||
* message contradicts them and takes 200px of height while doing it.
|
||||
*/
|
||||
|
||||
@@ -61,7 +61,8 @@ export type BackgroundJobSpec = {
|
||||
const durationLine = (d: BackgroundJobSpec['duration']): string =>
|
||||
d === null
|
||||
? 'Run until I stop it. Do not bound the loop by time.'
|
||||
: `Run for about ${d} minute${d === 1 ? '' : 's'}, then exit 1 to say it timed out.`;
|
||||
: // i18n-exempt: agent prompt — this whole module builds the brief the MODEL executes
|
||||
`Run for about ${d} minute${d === 1 ? '' : 's'}, then exit 1 to say it timed out.`;
|
||||
|
||||
/**
|
||||
* Rule 7 — what happens on fire. `stop` and `handoff` both exit; only the sentence after that differs,
|
||||
@@ -200,6 +201,7 @@ export function buildBackgroundJobPrompt(spec: BackgroundJobSpec): string {
|
||||
``,
|
||||
what,
|
||||
``,
|
||||
// i18n-exempt: agent prompt — this whole module builds the brief the MODEL executes
|
||||
`WORKING DIRECTORY: ${spec.cwd}`,
|
||||
``,
|
||||
`MECHANICS:`,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
type AttachButtonProps = {
|
||||
/**
|
||||
@@ -37,6 +38,7 @@ export function AttachButton({
|
||||
onAttachText,
|
||||
size = 'sm',
|
||||
}: AttachButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const pdfInputRef = useRef<HTMLInputElement>(null);
|
||||
const textInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -76,7 +78,7 @@ export function AttachButton({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Attach"
|
||||
aria-label={t('chat.attach.attach')}
|
||||
className={`shrink-0 ${sizeClasses} flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-pointer`}
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
@@ -86,7 +88,7 @@ export function AttachButton({
|
||||
{onAttachImage && (
|
||||
<DropdownMenuItem className="cursor-pointer" onSelect={() => imageInputRef.current?.click()}>
|
||||
<Image className="mr-2 h-4 w-4" />
|
||||
Image
|
||||
{t('chat.attach.image')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* PDF sat here once with no `onSelect` at all — the menu offered it and did nothing, because
|
||||
@@ -102,11 +104,11 @@ export function AttachButton({
|
||||
)}
|
||||
<DropdownMenuItem className="cursor-pointer" onSelect={() => textInputRef.current?.click()}>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Text File
|
||||
{t('chat.attach.textFile')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer" onSelect={onAttachWebpage}>
|
||||
<Link className="mr-2 h-4 w-4" />
|
||||
Webpage URL
|
||||
{t('chat.attach.webpageUrl')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { buildBackgroundJobPrompt, type BackgroundJobSpec, type FireBehavior } from '../background-job-prompt';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
// Spawning a background job.
|
||||
//
|
||||
@@ -64,6 +65,7 @@ export const BackgroundJobDialog = ({
|
||||
isCommander = false,
|
||||
recordPaths,
|
||||
}: BackgroundJobDialogProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [mode, setMode] = useState<'command' | 'prompt'>('command');
|
||||
const [body, setBody] = useState('');
|
||||
const [watching, setWatching] = useState(false);
|
||||
@@ -130,10 +132,11 @@ export const BackgroundJobDialog = ({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<CirclePlay className={`h-4 w-4 ${toneText.warning}`} />
|
||||
Background job
|
||||
{t('chat.job.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Runs in <code className="font-mono text-xs">{cwd}</code>, tracked by the harness — if it dies, you are told.
|
||||
{t('chat.job.runsIn')} <code className="font-mono text-xs">{cwd}</code>, tracked by the harness — if it
|
||||
dies, you are told.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -146,11 +149,11 @@ export const BackgroundJobDialog = ({
|
||||
disabled={watching}
|
||||
onChange={() => setMode('command')}
|
||||
/>
|
||||
<span className={watching ? 'opacity-40' : ''}>Command — run exactly this</span>
|
||||
<span className={watching ? 'opacity-40' : ''}>{t('chat.job.command')}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="radio" checked={mode === 'prompt'} onChange={() => setMode('prompt')} />
|
||||
<span>Prompt — describe it, the agent works it out</span>
|
||||
<span>{t('chat.job.prompt')}</span>
|
||||
</label>
|
||||
<Input
|
||||
autoFocus
|
||||
@@ -164,7 +167,7 @@ export const BackgroundJobDialog = ({
|
||||
<div className="space-y-2 rounded-md border border-duck-dark/10 p-3">
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input type="checkbox" checked={watching} onChange={(ev) => toggleWatching(ev.target.checked)} />
|
||||
Watch for something
|
||||
{t('chat.job.watch')}
|
||||
</label>
|
||||
{watching && (
|
||||
<>
|
||||
@@ -172,17 +175,15 @@ export const BackgroundJobDialog = ({
|
||||
value={condition}
|
||||
onChange={(ev) => setCondition(ev.target.value)}
|
||||
rows={3}
|
||||
placeholder="watch our Gitea repos; on any new push, pull it and push to the GitHub mirror"
|
||||
placeholder={t('chat.job.watchExample')}
|
||||
className="text-sm"
|
||||
/>
|
||||
<p className="text-xs text-duck-dark/50">
|
||||
In prose. The agent will ask before arming if anything is unclear.
|
||||
</p>
|
||||
<p className="text-xs text-duck-dark/50">{t('chat.job.watchHint')}</p>
|
||||
<div className="space-y-1 pt-1">
|
||||
{isCommander && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="radio" checked={onFire === 'handoff'} onChange={() => setOnFire('handoff')} />
|
||||
Exit and hand back to the agent, then re-arm
|
||||
{t('chat.job.handBack')}
|
||||
</label>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
@@ -191,7 +192,7 @@ export const BackgroundJobDialog = ({
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="radio" checked={onFire === 'keep-going'} onChange={() => setOnFire('keep-going')} />
|
||||
Keep going — act each time and carry on
|
||||
{t('chat.job.keepGoing')}
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
@@ -201,7 +202,7 @@ export const BackgroundJobDialog = ({
|
||||
<div className="space-y-1">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={timed} onChange={(ev) => setTimed(ev.target.checked)} />
|
||||
Stop after
|
||||
{t('chat.job.stopAfter')}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -222,7 +223,7 @@ export const BackgroundJobDialog = ({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
Start
|
||||
{t('chat.job.start')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useBackgroundTasks, useTaskDetail, type BackgroundTask } from '../useBa
|
||||
import { SubagentTrace } from './ToolActivity';
|
||||
import { CodeSurface } from './CodeSurface';
|
||||
import { markUserBackgroundJob, markStopRequested } from '../../../background-job-origin';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
type BackgroundTaskTrayProps = {
|
||||
messages: ChatMessage[];
|
||||
@@ -35,6 +36,7 @@ function statusTone(status: BackgroundTask['status']): Tone {
|
||||
* tail of a backgrounded shell's log, both read from the file Claude Code streams the task into.
|
||||
*/
|
||||
export const BackgroundTaskTray = ({ messages, sessionId }: BackgroundTaskTrayProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { tasks, pinned, unpinned, running, clearable, togglePin, dismiss, dismissFinished } =
|
||||
useBackgroundTasks(messages);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
@@ -189,7 +191,7 @@ export const BackgroundTaskTray = ({ messages, sessionId }: BackgroundTaskTrayPr
|
||||
)}
|
||||
|
||||
{pinned.length > 0 && (
|
||||
<TaskStrip label="Pinned" divided={unpinned.length > 0}>
|
||||
<TaskStrip label={t('chat.tasks.pinned')} divided={unpinned.length > 0}>
|
||||
{pinned.map((task) => (
|
||||
<TaskChip key={task.taskId} pinned {...chipProps(task)} />
|
||||
))}
|
||||
@@ -204,8 +206,8 @@ export const BackgroundTaskTray = ({ messages, sessionId }: BackgroundTaskTrayPr
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismissFinished}
|
||||
title="Clear all finished"
|
||||
aria-label="Clear all finished tasks"
|
||||
title={t('chat.tasks.clearFinished')}
|
||||
aria-label={t('chat.tasks.clearFinishedTasks')}
|
||||
className="shrink-0 cursor-pointer rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
@@ -260,6 +262,7 @@ type TaskChipProps = {
|
||||
* nested in a button is invalid, and the browser resolves it by swallowing one of the two clicks.
|
||||
*/
|
||||
const TaskChip = ({ task, active, pinned, onClick, onTogglePin, onDismiss, onStop }: TaskChipProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { status } = task;
|
||||
const tone = toneText[statusTone(status)];
|
||||
const label = task.description || 'Background task';
|
||||
@@ -312,7 +315,7 @@ const TaskChip = ({ task, active, pinned, onClick, onTogglePin, onDismiss, onSto
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
title="Stop this job"
|
||||
title={t('chat.tasks.stopJob')}
|
||||
aria-label={`Stop ${label}`}
|
||||
className="cursor-pointer py-1 pl-0.5 pr-2 text-muted-foreground/50 transition-colors hover:text-destructive"
|
||||
>
|
||||
@@ -323,7 +326,7 @@ const TaskChip = ({ task, active, pinned, onClick, onTogglePin, onDismiss, onSto
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
title="Dismiss"
|
||||
title={t('chat.tasks.dismiss')}
|
||||
aria-label={`Dismiss ${label}`}
|
||||
className="cursor-pointer rounded-full py-1 pl-0.5 pr-2 text-muted-foreground/50 transition-colors hover:text-foreground"
|
||||
>
|
||||
@@ -361,6 +364,7 @@ const TaskPanel = ({
|
||||
onRestart,
|
||||
paused = false,
|
||||
}: TaskPanelProps) => {
|
||||
const { t } = useTranslation();
|
||||
const running = !task.status;
|
||||
const { data, isLoading } = useTaskDetail(task.taskId, running);
|
||||
const bodyRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -411,11 +415,11 @@ const TaskPanel = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRestart}
|
||||
title="Stop it and run the same command again"
|
||||
title={t('chat.tasks.rerun')}
|
||||
className="flex shrink-0 cursor-pointer items-center gap-1 rounded border border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:border-duck-teal/40 hover:text-duck-teal"
|
||||
>
|
||||
<RotateCw className="h-3 w-3" />
|
||||
Restart
|
||||
{t('chat.tasks.restart')}
|
||||
</button>
|
||||
)}
|
||||
{running && onStop && (
|
||||
@@ -425,7 +429,7 @@ const TaskPanel = ({
|
||||
title={
|
||||
killOffered
|
||||
? 'It ignored the request to stop. Kill it — no chance to clean up, release a port or take children with it.'
|
||||
: 'Stop this job'
|
||||
: t('chat.tasks.stopJob')
|
||||
}
|
||||
className={`flex shrink-0 cursor-pointer items-center gap-1 rounded border px-2 py-1 text-xs transition-colors ${
|
||||
killOffered
|
||||
@@ -434,14 +438,14 @@ const TaskPanel = ({
|
||||
}`}
|
||||
>
|
||||
{killOffered ? <Skull className="h-3 w-3" /> : <Square className="h-3 w-3" />}
|
||||
{killOffered ? 'Kill' : 'Stop'}
|
||||
{killOffered ? 'Kill' : t('chat.composer.stop')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
title="Collapse"
|
||||
aria-label="Collapse task detail"
|
||||
title={t('chat.tasks.collapse')}
|
||||
aria-label={t('chat.tasks.collapseDetail')}
|
||||
className="shrink-0 cursor-pointer rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
/**
|
||||
* An image inside a chat turn: a thumbnail in the bubble, full size when clicked.
|
||||
@@ -12,6 +13,7 @@ import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
* take comparable room, instead of one of them owning the pane. Same rule the mobile client uses.
|
||||
*/
|
||||
export const ChatImage = ({ src, alt }: { src?: string; alt?: string }) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
@@ -47,7 +49,7 @@ export const ChatImage = ({ src, alt }: { src?: string; alt?: string }) => {
|
||||
{/* No chrome and no padding: the picture is the content, and a header would take height from
|
||||
it on the one screen where height is what you came for. */}
|
||||
<DialogContent className="max-w-[95vw] w-auto p-0 bg-transparent border-0 shadow-none">
|
||||
<DialogTitle className="sr-only">{alt || 'Image'}</DialogTitle>
|
||||
<DialogTitle className="sr-only">{alt || t('chat.attach.image')}</DialogTitle>
|
||||
<img src={src} alt={alt ?? ''} className="max-h-[90vh] max-w-[95vw] w-auto rounded-lg" />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { errorText } from 'helpers/error-text';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
/*
|
||||
`/login` — the same flow as the terminal, without the terminal.
|
||||
@@ -24,6 +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 client = useClient();
|
||||
const [started, setStarted] = useState<Started | null>(null);
|
||||
const [code, setCode] = useState('');
|
||||
@@ -78,13 +80,13 @@ export const ClaudeLoginDialog = ({ open, onOpenChange }: Props) => {
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sign in to Claude</DialogTitle>
|
||||
<DialogTitle>{t('chat.signIn.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{done ? (
|
||||
<div className="flex items-center gap-2 py-4 text-sm">
|
||||
<Check className="h-4 w-4 text-success" />
|
||||
Signed in. New turns use these credentials — no restart needed.
|
||||
{t('chat.signIn.done')}
|
||||
</div>
|
||||
) : !started ? (
|
||||
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
|
||||
@@ -93,16 +95,14 @@ export const ClaudeLoginDialog = ({ open, onOpenChange }: Props) => {
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Asking the CLI for a sign-in link…
|
||||
{t('chat.signIn.asking')}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Open this, authorise, and paste back the code it gives you.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t('chat.signIn.openHint')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* A real anchor. Every link here opens a new tab, which is what you want for this one —
|
||||
losing the dialog would lose the process holding the other half of the exchange. */}
|
||||
@@ -112,7 +112,7 @@ export const ClaudeLoginDialog = ({ open, onOpenChange }: Props) => {
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90"
|
||||
>
|
||||
Authorise in claude.com
|
||||
{t('chat.signIn.authorise')}
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
{/* Copy, because opening here opens in THIS browser — already signed into whichever account
|
||||
@@ -128,7 +128,7 @@ export const ClaudeLoginDialog = ({ open, onOpenChange }: Props) => {
|
||||
else setError('could not reach the clipboard — select the link and copy it by hand');
|
||||
});
|
||||
}}
|
||||
title="Copy the link, to open it in another browser or on your phone"
|
||||
title={t('chat.signIn.copyLink')}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-foreground/5"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-success" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
@@ -146,7 +146,7 @@ export const ClaudeLoginDialog = ({ open, onOpenChange }: Props) => {
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') void submit();
|
||||
}}
|
||||
placeholder="paste the code"
|
||||
placeholder={t('chat.signIn.pasteCode')}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { highlight } from '../../FileViewer/renderers/highlight';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
/**
|
||||
* The one way this app draws a block of code: shiki-highlighted when a language is known, line-numbered,
|
||||
@@ -53,6 +54,7 @@ type CodeSurfaceProps = {
|
||||
};
|
||||
|
||||
export const CodeSurface = ({ code, lang = '', startLine = 1, maxLines, numbered, fallback }: CodeSurfaceProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState<CopyMode | null>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// Keyed by the source it was produced from. While a block is still streaming the stored html belongs to
|
||||
@@ -128,8 +130,8 @@ export const CodeSurface = ({ code, lang = '', startLine = 1, maxLines, numbered
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopy('raw')}
|
||||
title="Copy the code"
|
||||
aria-label="Copy the code"
|
||||
title={t('chat.message.copyCode')}
|
||||
aria-label={t('chat.message.copyCode')}
|
||||
className={CONTROL}
|
||||
>
|
||||
{copied === 'raw' ? <Check className="h-3.5 w-3.5 text-success" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
@@ -137,8 +139,8 @@ export const CodeSurface = ({ code, lang = '', startLine = 1, maxLines, numbered
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopy('md')}
|
||||
title="Copy as a markdown block"
|
||||
aria-label="Copy as a markdown block"
|
||||
title={t('chat.message.copyMarkdown')}
|
||||
aria-label={t('chat.message.copyMarkdown')}
|
||||
className={CONTROL}
|
||||
>
|
||||
{copied === 'md' ? (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
type CopyButtonProps = {
|
||||
text: string;
|
||||
@@ -8,6 +9,7 @@ type CopyButtonProps = {
|
||||
};
|
||||
|
||||
export const CopyButton = ({ text, className = '' }: CopyButtonProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
@@ -21,7 +23,7 @@ export const CopyButton = ({ text, className = '' }: CopyButtonProps) => {
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className={`p-1 rounded text-foreground opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity cursor-pointer ${className}`}
|
||||
title="Copy"
|
||||
title={t('chat.copy')}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-success" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { AttachmentList } from './AttachmentList';
|
||||
import { AttachButton } from './AttachButton';
|
||||
import { WebpageDialog } from './WebpageDialog';
|
||||
import { BackgroundTaskTray } from './BackgroundTaskTray';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
type InputAreaProps = {
|
||||
manager: UseEmbeddableChatType;
|
||||
@@ -21,6 +22,7 @@ type InputAreaProps = {
|
||||
};
|
||||
|
||||
export const InputArea = ({ manager, disabled }: InputAreaProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
// Only for a commander, and only once it has an id. Everything a commander's loop needs in order to be
|
||||
// stoppable and deletable hangs off these two paths — see api/command-hq/spawned.ts.
|
||||
@@ -88,7 +90,7 @@ export const InputArea = ({ manager, disabled }: InputAreaProps) => {
|
||||
>
|
||||
{drop.isTarget && (
|
||||
<div className="pointer-events-none absolute inset-1 z-10 flex items-center justify-center rounded-lg border-2 border-dashed border-duck-teal bg-background/80">
|
||||
<span className="text-xs font-medium text-duck-teal">Drop images to attach</span>
|
||||
<span className="text-xs font-medium text-duck-teal">{t('chat.attach.dropImages')}</span>
|
||||
</div>
|
||||
)}
|
||||
{commandFeedback && (
|
||||
@@ -158,7 +160,7 @@ export const InputArea = ({ manager, disabled }: InputAreaProps) => {
|
||||
onClick={stopGeneration}
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
title="Stop"
|
||||
title={t('chat.composer.stop')}
|
||||
className="shrink-0 h-7 w-7 md:h-9 md:w-9 cursor-pointer"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
@@ -177,7 +179,7 @@ export const InputArea = ({ manager, disabled }: InputAreaProps) => {
|
||||
}}
|
||||
disabled={!input.trim() || !isConnected}
|
||||
size="icon"
|
||||
title={isGenerating ? 'Queue — sends when the current turn finishes' : 'Send'}
|
||||
title={isGenerating ? 'Queue — sends when the current turn finishes' : t('chat.composer.send')}
|
||||
className={`shrink-0 h-7 w-7 md:h-9 md:w-9 cursor-pointer disabled:opacity-40 ${
|
||||
isGenerating ? 'bg-busy hover:bg-busy/90 text-busy-foreground' : 'bg-duck-teal hover:bg-duck-teal/90'
|
||||
}`}
|
||||
@@ -307,6 +309,7 @@ const QueuedList = ({
|
||||
onRemove: (id: string) => void;
|
||||
onSendNow: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
if (queued.length === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -316,7 +319,7 @@ const QueuedList = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSendNow}
|
||||
title="Send these into the turn that is already running"
|
||||
title={t('chat.composer.sendIntoTurn')}
|
||||
className="cursor-pointer self-start rounded px-1 text-[11px] text-busy-ink/80 transition-colors hover:bg-busy/10 hover:text-busy-ink"
|
||||
>
|
||||
Send now — press Enter on an empty box to add {queued.length === 1 ? 'this' : 'these'} to the running turn
|
||||
@@ -328,7 +331,7 @@ const QueuedList = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(item.id)}
|
||||
title="Remove from queue"
|
||||
title={t('chat.composer.removeFromQueue')}
|
||||
className="shrink-0 cursor-pointer rounded p-0.5 text-muted-foreground transition-colors hover:bg-black/10 hover:text-foreground"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
|
||||
@@ -36,6 +36,7 @@ import { CodeBlock } from './CodeBlock';
|
||||
import { ChatImage } from './ChatImage';
|
||||
import { injectImages } from './chat-images';
|
||||
import { randomId } from 'helpers/random-id';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
/** Shared by the settled bubble and the streaming one, so a block gains nothing when the turn ends. */
|
||||
/**
|
||||
@@ -85,6 +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 [state, setState] = useState<TtsState>('idle');
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const api = useFilesAPI('user-data');
|
||||
@@ -111,7 +113,7 @@ const ReadAloudButton = ({ id, text }: { id: string; text: string }) => {
|
||||
audio.onerror = () => {
|
||||
audioRef.current = null;
|
||||
setState('idle');
|
||||
toast.error('The narration was generated but would not play.');
|
||||
toast.error(t('chat.message.narrationFailed'));
|
||||
};
|
||||
await audio.play();
|
||||
setState('playing');
|
||||
@@ -153,6 +155,7 @@ type MessageBubbleProps = {
|
||||
};
|
||||
|
||||
export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaultOpen }: MessageBubbleProps) => {
|
||||
const { t } = useTranslation();
|
||||
switch (message.role) {
|
||||
case 'user': {
|
||||
const rawText = typeof message.text === 'string' ? message.text : '';
|
||||
@@ -161,7 +164,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
|
||||
const text = match ? rawText.slice(match[0]!.length) : rawText;
|
||||
return (
|
||||
<>
|
||||
{metadata && <CollapsibleBlock label="Frontmatter" content={metadata} />}
|
||||
{metadata && <CollapsibleBlock label={t('chat.message.frontmatter')} content={metadata} />}
|
||||
<div className="flex justify-end group">
|
||||
<div className="max-w-[80%] min-w-0 rounded-2xl rounded-tr-sm bg-duck-yellow/10 border border-duck-yellow/20 px-4 py-2.5 text-sm text-foreground">
|
||||
{message.images?.map((img, i) => (
|
||||
@@ -203,7 +206,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
|
||||
}
|
||||
|
||||
case 'system':
|
||||
return <CollapsibleBlock label="System prompt" content={message.text} />;
|
||||
return <CollapsibleBlock label={t('chat.message.systemPrompt')} content={message.text} />;
|
||||
|
||||
// The one seam in a merged `/clear` chain. It says what was lost rather than just drawing a line,
|
||||
// because the risk of merging is precisely that the conversation looks unbroken to you and is not:
|
||||
@@ -214,7 +217,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
|
||||
<Eraser className="h-3 w-3" />
|
||||
Context cleared — nothing above this is in memory
|
||||
{t('chat.message.contextCleared')}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
@@ -287,7 +290,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
|
||||
<CircleSlash className="h-3 w-3" />
|
||||
Interrupted by user
|
||||
{t('chat.message.interrupted')}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
@@ -303,7 +306,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
|
||||
<PlugZap className="h-3 w-3" />
|
||||
Agent restarted — turn cut off
|
||||
{t('chat.message.agentRestarted')}
|
||||
</span>
|
||||
{message.prompt && onRetry && (
|
||||
<button
|
||||
@@ -312,7 +315,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
|
||||
className="flex cursor-pointer items-center gap-1 rounded border border-border px-1.5 py-0.5 text-[11px] font-medium tracking-wide uppercase transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Retry
|
||||
{t('chat.message.retry')}
|
||||
</button>
|
||||
)}
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
@@ -455,27 +458,30 @@ const ModelSwitch = ({ message }: { message: Extract<ChatMessage, { role: 'model
|
||||
* a fault: in plan mode the agent researches and proposes but will not edit anything, and a transcript with
|
||||
* no notice in it reads as an agent that inexplicably stopped doing the work.
|
||||
*/
|
||||
const PlanModeSwitch = ({ message }: { message: Extract<ChatMessage, { role: 'planmode' }> }) => (
|
||||
<div className="flex items-center gap-3 py-2 text-muted-foreground select-none">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
|
||||
<ClipboardList className="h-3 w-3" />
|
||||
{message.ok ? (
|
||||
message.enabled ? (
|
||||
'Plan mode on — research only'
|
||||
const PlanModeSwitch = ({ message }: { message: Extract<ChatMessage, { role: 'planmode' }> }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2 text-muted-foreground select-none">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
|
||||
<ClipboardList className="h-3 w-3" />
|
||||
{message.ok ? (
|
||||
message.enabled ? (
|
||||
'Plan mode on — research only'
|
||||
) : (
|
||||
'Plan mode off'
|
||||
)
|
||||
) : (
|
||||
'Plan mode off'
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
Plan mode unchanged
|
||||
<span className="normal-case opacity-70">· {message.error ?? 'the agent refused'}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
);
|
||||
<>
|
||||
{t('chat.message.planUnchanged')}
|
||||
<span className="normal-case opacity-70">· {message.error ?? 'the agent refused'}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A plan, with the two buttons the agent is waiting on.
|
||||
@@ -496,6 +502,7 @@ const PlanCard = ({
|
||||
message: Extract<ChatMessage, { role: 'plan' }>;
|
||||
onDecide?: (requestId: string, approve: boolean, feedback?: string) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
const decided = message.approved !== undefined;
|
||||
@@ -535,7 +542,7 @@ const PlanCard = ({
|
||||
autoFocus
|
||||
value={feedback}
|
||||
onChange={(ev) => setFeedback(ev.target.value)}
|
||||
placeholder="What should change? The agent revises the plan from this."
|
||||
placeholder={t('chat.message.reviseHint')}
|
||||
rows={2}
|
||||
className="w-full resize-none rounded border border-border bg-background px-2 py-1.5 text-xs outline-none focus:border-primary/50"
|
||||
/>
|
||||
@@ -564,7 +571,7 @@ const PlanCard = ({
|
||||
className="flex cursor-pointer items-center gap-1 rounded bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90"
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
Approve
|
||||
{t('chat.message.approve')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -579,17 +586,20 @@ const PlanCard = ({
|
||||
* render nothing at all, because the streaming bubble bailed on empty text. The transcript looked frozen
|
||||
* and the only evidence the turn was alive was the send button having become a stop button.
|
||||
*/
|
||||
const ThinkingDots = () => (
|
||||
<span className="flex items-center gap-1 py-1" role="status" aria-label="Waiting for a response">
|
||||
{[0, 150, 300].map((delay) => (
|
||||
<span
|
||||
key={delay}
|
||||
className="h-1.5 w-1.5 animate-pulse rounded-full bg-muted-foreground/60"
|
||||
style={{ animationDelay: `${delay}ms` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
const ThinkingDots = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span className="flex items-center gap-1 py-1" role="status" aria-label={t('chat.message.waiting')}>
|
||||
{[0, 150, 300].map((delay) => (
|
||||
<span
|
||||
key={delay}
|
||||
className="h-1.5 w-1.5 animate-pulse rounded-full bg-muted-foreground/60"
|
||||
style={{ animationDelay: `${delay}ms` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
type StreamingBubbleProps = {
|
||||
text: string;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { buildRows } from './turn-rows';
|
||||
import { useFoldExit } from './fold-exit';
|
||||
import { ToolOpenProvider } from './tool-open';
|
||||
import { TurnEnd, TurnFold } from './TurnFold';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
const OVERSCAN = 5;
|
||||
|
||||
@@ -16,6 +17,7 @@ type MessageListProps = {
|
||||
};
|
||||
|
||||
export const MessageList = ({ manager }: MessageListProps) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
messages,
|
||||
historicalCount,
|
||||
@@ -115,7 +117,7 @@ export const MessageList = ({ manager }: MessageListProps) => {
|
||||
manager.emptyState
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full min-h-[200px] text-muted-foreground text-sm">
|
||||
Send a message to start
|
||||
{t('chat.composer.startHint')}
|
||||
</div>
|
||||
))}
|
||||
{rows.length > 0 && (
|
||||
@@ -175,7 +177,7 @@ export const MessageList = ({ manager }: MessageListProps) => {
|
||||
{showJumpToBottom && (
|
||||
<button
|
||||
onClick={jumpToBottom}
|
||||
aria-label="Jump to latest message"
|
||||
aria-label={t('chat.composer.jumpToLatest')}
|
||||
className="absolute bottom-2 left-1/2 -translate-x-1/2 cursor-pointer rounded-full bg-primary p-1.5 text-primary-foreground shadow-lg transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ModelOption } from 'state/useModels';
|
||||
import { getProviderDisplayName } from 'state/useModels';
|
||||
import type { ChatMessage } from '../types';
|
||||
import { UsagePopover } from './UsagePopover';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
const PROVIDER_DISPLAY: Record<string, string> = {
|
||||
anthropic: 'Anthropic',
|
||||
@@ -67,6 +68,7 @@ export function ModelSelector({
|
||||
planMode = false,
|
||||
onPlanModeChange,
|
||||
}: ModelSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const providers = [...new Set(availableModels.map((m) => m.provider).filter(Boolean))] as string[];
|
||||
|
||||
// Determine which model to display: selectedModel takes precedence, then model (from server), then fallback
|
||||
@@ -221,7 +223,7 @@ export function ModelSelector({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBackgroundJob}
|
||||
title="Start a background job or watcher"
|
||||
title={t('chat.job.startJob')}
|
||||
className={`shrink-0 cursor-pointer rounded-md p-1.5 transition-colors hover:bg-foreground/10 ${toneText.warning}`}
|
||||
>
|
||||
<CirclePlay className="h-[18px] w-[18px]" />
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { render, fireEvent, screen } from '@testing-library/react';
|
||||
import { I18nProvider } from 'i18n';
|
||||
import { QuestionActivity } from './QuestionActivity';
|
||||
import panelEnGB from '../../../locales/en-GB.json';
|
||||
import type { ChatMessage } from '../types';
|
||||
|
||||
/**
|
||||
* The component's own chrome ("Other...", "Send") comes from the `panel.*` catalogue now, so it only
|
||||
* reads as English inside a provider — the same one the app mounts. Asserting the words a person sees,
|
||||
* rather than loosening these to match a dot-path, is the point of wrapping.
|
||||
*/
|
||||
const withI18n = (ui: ReactNode) => (
|
||||
<I18nProvider translations={{ 'en-GB': { panel: panelEnGB } }} locale="en-GB" fallbackLocale="en-GB">
|
||||
{ui}
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
// The bug this file exists for: a Claude session's questions rendered as raw JSON for months because the
|
||||
// dispatch matched only OpenCode's tool name. Nothing threw — it read as "the model never asks". So what
|
||||
// is pinned here is the SHAPE both harnesses send, not the styling.
|
||||
@@ -19,10 +33,12 @@ describe('the two harnesses disagree on one field name', () => {
|
||||
test("Claude Code's `multiSelect` is honoured — several options, one submit", () => {
|
||||
const answers: string[] = [];
|
||||
render(
|
||||
<QuestionActivity
|
||||
message={ask([{ question: 'Which?', header: 'Store', multiSelect: true, options: OPTIONS }])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
withI18n(
|
||||
<QuestionActivity
|
||||
message={ask([{ question: 'Which?', header: 'Store', multiSelect: true, options: OPTIONS }])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByText('Postgres'));
|
||||
fireEvent.click(screen.getByText('SQLite'));
|
||||
@@ -36,10 +52,12 @@ describe('the two harnesses disagree on one field name', () => {
|
||||
test("OpenCode's `multiple` means the same thing", () => {
|
||||
const answers: string[] = [];
|
||||
render(
|
||||
<QuestionActivity
|
||||
message={ask([{ question: 'Which?', header: 'Store', multiple: true, options: OPTIONS }])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
withI18n(
|
||||
<QuestionActivity
|
||||
message={ask([{ question: 'Which?', header: 'Store', multiple: true, options: OPTIONS }])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByText('Postgres'));
|
||||
fireEvent.click(screen.getByText(/Submit \(1 selected\)/));
|
||||
@@ -49,10 +67,12 @@ describe('the two harnesses disagree on one field name', () => {
|
||||
test('neither field set means single-select — one click answers', () => {
|
||||
const answers: string[] = [];
|
||||
render(
|
||||
<QuestionActivity
|
||||
message={ask([{ question: 'Which?', header: 'Store', options: OPTIONS }])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
withI18n(
|
||||
<QuestionActivity
|
||||
message={ask([{ question: 'Which?', header: 'Store', options: OPTIONS }])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByText('SQLite'));
|
||||
expect(answers).toEqual(['SQLite']);
|
||||
@@ -65,20 +85,22 @@ describe('more than one question', () => {
|
||||
test('nothing is sent until every question has an answer', () => {
|
||||
const answers: string[] = [];
|
||||
render(
|
||||
<QuestionActivity
|
||||
message={ask([
|
||||
{ question: 'Which store?', header: 'Store', options: OPTIONS },
|
||||
{
|
||||
question: 'Which runtime?',
|
||||
header: 'Runtime',
|
||||
options: [
|
||||
{ label: 'Bun', description: 'what the repo uses' },
|
||||
{ label: 'Node', description: 'the other one' },
|
||||
],
|
||||
},
|
||||
])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
withI18n(
|
||||
<QuestionActivity
|
||||
message={ask([
|
||||
{ question: 'Which store?', header: 'Store', options: OPTIONS },
|
||||
{
|
||||
question: 'Which runtime?',
|
||||
header: 'Runtime',
|
||||
options: [
|
||||
{ label: 'Bun', description: 'what the repo uses' },
|
||||
{ label: 'Node', description: 'the other one' },
|
||||
],
|
||||
},
|
||||
])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Postgres'));
|
||||
@@ -95,10 +117,12 @@ describe('the escape hatch', () => {
|
||||
test('free text answers the question', () => {
|
||||
const answers: string[] = [];
|
||||
render(
|
||||
<QuestionActivity
|
||||
message={ask([{ question: 'Which?', header: 'Store', options: OPTIONS }])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
withI18n(
|
||||
<QuestionActivity
|
||||
message={ask([{ question: 'Which?', header: 'Store', options: OPTIONS }])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
fireEvent.change(screen.getByPlaceholderText('Other...'), { target: { value: 'DuckDB' } });
|
||||
fireEvent.click(screen.getByText('Send'));
|
||||
@@ -124,7 +148,7 @@ describe('a question that already carries an output', () => {
|
||||
...ask([{ question: 'Which?', header: 'Store', options: OPTIONS }]),
|
||||
output: 'Postgres',
|
||||
} as never;
|
||||
render(<QuestionActivity message={answered} onAnswer={(t) => answers.push(t)} />);
|
||||
render(withI18n(<QuestionActivity message={answered} onAnswer={(t) => answers.push(t)} />));
|
||||
fireEvent.click(screen.getByText('Postgres'));
|
||||
expect(answers).toEqual([]);
|
||||
});
|
||||
@@ -135,7 +159,7 @@ describe('a question that already carries an output', () => {
|
||||
...ask([{ question: 'Which?', header: 'Store', options: OPTIONS }]),
|
||||
output: 'The user did not answer the questions.',
|
||||
} as never;
|
||||
render(<QuestionActivity message={stale} onAnswer={(t) => answers.push(t)} />);
|
||||
render(withI18n(<QuestionActivity message={stale} onAnswer={(t) => answers.push(t)} />));
|
||||
fireEvent.click(screen.getByText('Postgres'));
|
||||
expect(answers).toEqual([]);
|
||||
});
|
||||
@@ -143,10 +167,12 @@ describe('a question that already carries an output', () => {
|
||||
test('while a LIVE question — no output yet — is answerable exactly once', () => {
|
||||
const answers: string[] = [];
|
||||
render(
|
||||
<QuestionActivity
|
||||
message={ask([{ question: 'Which?', header: 'Store', options: OPTIONS }])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
withI18n(
|
||||
<QuestionActivity
|
||||
message={ask([{ question: 'Which?', header: 'Store', options: OPTIONS }])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByText('Postgres'));
|
||||
fireEvent.click(screen.getByText('SQLite'));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { MessageCircleQuestion, Check, Loader2 } from 'lucide-react';
|
||||
import type { ChatMessage } from '../types';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
|
||||
|
||||
@@ -55,6 +56,7 @@ const isMulti = (q: Question): boolean => q.multiSelect ?? q.multiple ?? false;
|
||||
* grows an answer channel this is the piece that already knows how to ask.
|
||||
*/
|
||||
export const QuestionActivity = ({ message, onAnswer }: QuestionActivityProps) => {
|
||||
const { t } = useTranslation();
|
||||
// Keyed by question INDEX, because a payload may carry up to four and they are answered independently.
|
||||
// This was single-valued state shared across every question, so with more than one the first click sent
|
||||
// a partial answer and locked the rest — invisible while only one question was ever asked.
|
||||
@@ -132,7 +134,7 @@ export const QuestionActivity = ({ message, onAnswer }: QuestionActivityProps) =
|
||||
{awaitingAgent && (
|
||||
<div className="flex items-center gap-2 px-1 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Answer sent — waiting for the agent</span>
|
||||
<span>{t('chat.message.answerSent')}</span>
|
||||
</div>
|
||||
)}
|
||||
{questions.map((q, qi) => {
|
||||
@@ -203,7 +205,7 @@ export const QuestionActivity = ({ message, onAnswer }: QuestionActivityProps) =
|
||||
type="text"
|
||||
value={otherText[qi] ?? ''}
|
||||
onChange={(ev) => setOtherText((prev) => ({ ...prev, [qi]: ev.target.value }))}
|
||||
placeholder="Other..."
|
||||
placeholder={t('chat.message.other')}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg border border-border text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-duck-teal/40"
|
||||
/>
|
||||
<button
|
||||
@@ -211,7 +213,7 @@ export const QuestionActivity = ({ message, onAnswer }: QuestionActivityProps) =
|
||||
disabled={!(otherText[qi] ?? '').trim()}
|
||||
className="px-3 py-1.5 rounded-lg bg-duck-teal text-white text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed hover:bg-duck-teal/90 transition-colors cursor-pointer"
|
||||
>
|
||||
Send
|
||||
{t('chat.composer.send')}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { diffStat, editDiff } from './line-diff';
|
||||
import { CodeSurface } from './CodeSurface';
|
||||
import { CopyButton } from './CopyButton';
|
||||
import { useToolOpen } from './tool-open';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
|
||||
|
||||
@@ -184,6 +185,7 @@ function langForPath(path: unknown): string {
|
||||
}
|
||||
|
||||
export const ToolActivity = ({ message, defaultOpen }: ToolActivityProps) => {
|
||||
const { t } = useTranslation();
|
||||
const Icon = toolIcons[message.toolName] ?? Wrench;
|
||||
const { head, tail } = splitSummary(getToolSummary(message.toolName, message.toolInput));
|
||||
const pending = message.output === undefined;
|
||||
@@ -266,7 +268,9 @@ export const ToolActivity = ({ message, defaultOpen }: ToolActivityProps) => {
|
||||
|
||||
{children.length > 0 && (
|
||||
<div className="rounded-md border-l-2 border-duck-teal/30 pl-2">
|
||||
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-1">Subagent</div>
|
||||
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-1">
|
||||
{t('chat.message.subagent')}
|
||||
</div>
|
||||
<SubagentTrace messages={children} />
|
||||
</div>
|
||||
)}
|
||||
@@ -274,7 +278,7 @@ export const ToolActivity = ({ message, defaultOpen }: ToolActivityProps) => {
|
||||
{message.output !== undefined && (
|
||||
<div className="rounded-md bg-muted/50 p-2">
|
||||
<div className="mb-1 text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{isError ? <span className="text-destructive">Output — error</span> : 'Output'}
|
||||
{isError ? <span className="text-destructive">{t('chat.message.outputError')}</span> : 'Output'}
|
||||
</div>
|
||||
<ToolOutput
|
||||
toolName={message.toolName}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { FoldCounts } from './turn-rows';
|
||||
import { foldLabel, keyOf } from './turn-rows';
|
||||
import { COLLAPSE_MS } from './fold-exit';
|
||||
import { MessageBubble } from './MessageBubble';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
type TurnFoldProps = {
|
||||
messages: ChatMessage[];
|
||||
@@ -86,13 +87,16 @@ export const TurnFold = ({ messages, counts, open, childrenOpen, onToggle }: Tur
|
||||
/**
|
||||
* A turn that ended without a closing message. Written as a seam, never as prose — see `turn-rows.ts`.
|
||||
*/
|
||||
export const TurnEnd = () => (
|
||||
<div className="flex items-center gap-3 py-2 text-muted-foreground select-none">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Turn finished · no closing message
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
);
|
||||
export const TurnEnd = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2 text-muted-foreground select-none">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
{t('chat.turnFinished')}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { UsageAccount } from '../useClaudeUsage';
|
||||
import { currentLimit, formatAge, formatReset, formatTokens, useClaudeUsage, weekdayLabel } from '../useClaudeUsage';
|
||||
import type { SwitchableAccount } from '../useAnthropicAccounts';
|
||||
import { useAnthropicAccounts } from '../useAnthropicAccounts';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
/**
|
||||
* Claude Code's own usage display, on the harness pill — for every account, not just the one signed in.
|
||||
@@ -43,36 +44,40 @@ const Section = ({ title, children }: { title: string; children: React.ReactNode
|
||||
</div>
|
||||
);
|
||||
|
||||
const Limits = ({ account, now }: { account: UsageAccount; now: number }) => (
|
||||
<Section title="Limits">
|
||||
<div className="flex flex-col gap-3">
|
||||
{account.limits.map((stored) => {
|
||||
// What the stored figure means NOW — a window that has rolled over since it was measured reads as
|
||||
// empty rather than as whatever it was when the account was last used.
|
||||
const limit = currentLimit(stored, now);
|
||||
const resets = formatReset(limit.resetsAt, now);
|
||||
return (
|
||||
<div key={limit.label} className="flex flex-col gap-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium text-foreground">{limit.label}</span>
|
||||
<span className="shrink-0 text-sm tabular-nums text-foreground">{Math.round(limit.percent)}%</span>
|
||||
const Limits = ({ account, now }: { account: UsageAccount; now: number }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Section title={t('chat.usage.limits')}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{account.limits.map((stored) => {
|
||||
// What the stored figure means NOW — a window that has rolled over since it was measured reads as
|
||||
// empty rather than as whatever it was when the account was last used.
|
||||
const limit = currentLimit(stored, now);
|
||||
const resets = formatReset(limit.resetsAt, now);
|
||||
return (
|
||||
<div key={limit.label} className="flex flex-col gap-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium text-foreground">{limit.label}</span>
|
||||
<span className="shrink-0 text-sm tabular-nums text-foreground">{Math.round(limit.percent)}%</span>
|
||||
</div>
|
||||
<Meter value={limit.percent / 100} strong />
|
||||
{resets && <span className="text-xs text-muted-foreground">Resets in {resets}</span>}
|
||||
</div>
|
||||
<Meter value={limit.percent / 100} strong />
|
||||
{resets && <span className="text-xs text-muted-foreground">Resets in {resets}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Days = ({ account }: { account: UsageAccount }) => {
|
||||
const { t } = useTranslation();
|
||||
// Every bar is scaled to the busiest day, so the shape of the week is readable even in a quiet one. A
|
||||
// fixed scale would flatten a whole week of ordinary work into nothing.
|
||||
const peak = Math.max(1, ...account.days.map((d) => d.tokens));
|
||||
|
||||
return (
|
||||
<Section title="Tokens by day">
|
||||
<Section title={t('chat.usage.byDay')}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{account.days.map((day, index) => {
|
||||
const today = index === account.days.length - 1;
|
||||
@@ -100,13 +105,14 @@ const Days = ({ account }: { account: UsageAccount }) => {
|
||||
};
|
||||
|
||||
const Models = ({ account }: { account: UsageAccount }) => {
|
||||
const { t } = useTranslation();
|
||||
// Four, because the tail is a long list of models that answered once. The heaviest is the scale, the same
|
||||
// way the week scales to its busiest day.
|
||||
const models = account.models.slice(0, 4);
|
||||
const peak = Math.max(1, ...models.map((m) => m.total));
|
||||
|
||||
return (
|
||||
<Section title="Tokens by model">
|
||||
<Section title={t('chat.usage.byModel')}>
|
||||
<div className="flex flex-col gap-1">
|
||||
{models.map((model) => (
|
||||
<div
|
||||
@@ -215,6 +221,7 @@ type UsagePopoverProps = {
|
||||
* these numbers are about.
|
||||
*/
|
||||
export function UsagePopover({ label, harness }: UsagePopoverProps) {
|
||||
const { t } = useTranslation();
|
||||
/*
|
||||
The owner check lives HERE rather than in the caller, so the composer does not have to know that a pill
|
||||
has anything to do with permissions.
|
||||
@@ -309,7 +316,7 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button type="button" className={`${pill} cursor-pointer hover:bg-foreground/10`} title="Usage and limits">
|
||||
<button type="button" className={`${pill} cursor-pointer hover:bg-foreground/10`} title={t('chat.usage.title')}>
|
||||
{label}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
@@ -342,9 +349,7 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
|
||||
{/* Said on the account being LOOKED AT rather than as a count somewhere else. The numbers below are
|
||||
real and the reset times are still correct; the only thing missing is the ability to act on
|
||||
them, and naming the fix beats naming the problem. */}
|
||||
{viewing && (
|
||||
<div className="mt-0.5 text-[10px] text-warning">No saved sign-in — run /login to use this one</div>
|
||||
)}
|
||||
{viewing && <div className="mt-0.5 text-[10px] text-warning">{t('chat.usage.noSignIn')}</div>}
|
||||
{/*
|
||||
How old this reading is, and it earns its line only when it is not live.
|
||||
|
||||
@@ -358,7 +363,7 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
|
||||
</div>
|
||||
)}
|
||||
{selected?.current && isRefreshing && (
|
||||
<div className="mt-0.5 text-[10px] text-muted-foreground">Updating…</div>
|
||||
<div className="mt-0.5 text-[10px] text-muted-foreground">{t('chat.usage.updating')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -386,7 +391,7 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
|
||||
still leaves real token history below it. */}
|
||||
{isError && (
|
||||
<div className="border-t border-border px-4 py-3 text-xs text-muted-foreground">
|
||||
Couldn't read usage on this machine.
|
||||
{t('chat.usage.unreadable')}
|
||||
</div>
|
||||
)}
|
||||
{selected?.limitsProblem && selected.current && (
|
||||
@@ -394,7 +399,9 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
|
||||
)}
|
||||
|
||||
{isLoading && !selected && (
|
||||
<div className="border-t border-border px-4 py-6 text-center text-xs text-muted-foreground">Loading…</div>
|
||||
<div className="border-t border-border px-4 py-6 text-center text-xs text-muted-foreground">
|
||||
{t('chat.usage.loading')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { FormEvent } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
type WebpageDialogProps = {
|
||||
open: boolean;
|
||||
@@ -10,6 +11,7 @@ type WebpageDialogProps = {
|
||||
};
|
||||
|
||||
export function WebpageDialog({ open, onOpenChange, onSubmit }: WebpageDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [urlInput, setUrlInput] = useState('');
|
||||
|
||||
const handleSubmit = (ev?: FormEvent) => {
|
||||
@@ -25,8 +27,8 @@ export function WebpageDialog({ open, onOpenChange, onSubmit }: WebpageDialogPro
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md z-[700]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Attach Webpage</DialogTitle>
|
||||
<DialogDescription>Enter a URL to scrape and attach as context.</DialogDescription>
|
||||
<DialogTitle>{t('chat.attach.webpageTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('chat.attach.webpageHint')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||
<input
|
||||
@@ -42,7 +44,7 @@ export function WebpageDialog({ open, onOpenChange, onSubmit }: WebpageDialogPro
|
||||
disabled={!urlInput.trim()}
|
||||
className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
||||
>
|
||||
Attach
|
||||
{t('chat.attach.attach')}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -315,5 +315,114 @@
|
||||
"newChatTitle": "New chat",
|
||||
"renameHint": "Click to rename this chat",
|
||||
"hideHidden": "Hide hidden"
|
||||
},
|
||||
"chat": {
|
||||
"newChat": "New chat",
|
||||
"rename": "Rename",
|
||||
"deleteQ": "Delete “{{title}}”?",
|
||||
"duplicateAgent": "There is already an agent named “{{name}}” on this dashboard",
|
||||
"composerPlaceholder": "What do you want to work on now?",
|
||||
"turnFinished": "Turn finished · no closing message",
|
||||
"job": {
|
||||
"title": "Background job",
|
||||
"runsIn": "Runs in",
|
||||
"command": "Command — run exactly this",
|
||||
"prompt": "Prompt — describe it, the agent works it out",
|
||||
"watch": "Watch for something",
|
||||
"watchHint": "In prose. The agent will ask before arming if anything is unclear.",
|
||||
"handBack": "Exit and hand back to the agent, then re-arm",
|
||||
"keepGoing": "Keep going — act each time and carry on",
|
||||
"stopAfter": "Stop after",
|
||||
"start": "Start",
|
||||
"watchExample": "watch our Gitea repos; on any new push, pull it and push to the GitHub mirror",
|
||||
"startJob": "Start a background job or watcher"
|
||||
},
|
||||
"tasks": {
|
||||
"restart": "Restart",
|
||||
"pinned": "Pinned",
|
||||
"clearFinished": "Clear all finished",
|
||||
"clearFinishedTasks": "Clear all finished tasks",
|
||||
"stopJob": "Stop this job",
|
||||
"dismiss": "Dismiss",
|
||||
"rerun": "Stop it and run the same command again",
|
||||
"collapse": "Collapse",
|
||||
"collapseDetail": "Collapse task detail",
|
||||
"stopNamed": "Stop {{label}}",
|
||||
"dismissNamed": "Dismiss {{label}}",
|
||||
"status": "Task {{status}}."
|
||||
},
|
||||
"usage": {
|
||||
"noSignIn": "No saved sign-in — run /login to use this one",
|
||||
"updating": "Updating…",
|
||||
"unreadable": "Couldn’t read usage on this machine.",
|
||||
"loading": "Loading…",
|
||||
"limits": "Limits",
|
||||
"byDay": "Tokens by day",
|
||||
"byModel": "Tokens by model",
|
||||
"title": "Usage and limits",
|
||||
"modelBreakdown": "In {{input}} · out {{output}} · cache read {{cacheRead}} · cache write {{cacheWrite}}",
|
||||
"switchTo": "Switch to {{account}}"
|
||||
},
|
||||
"attach": {
|
||||
"image": "Image",
|
||||
"textFile": "Text File",
|
||||
"webpageUrl": "Webpage URL",
|
||||
"attach": "Attach",
|
||||
"readFailed": "Could not read {{name}}: {{reason}}",
|
||||
"unknownError": "unknown error",
|
||||
"webpageTitle": "Attach Webpage",
|
||||
"webpageHint": "Enter a URL to scrape and attach as context.",
|
||||
"scrapeFailed": "Failed to scrape webpage",
|
||||
"uploadFailed": "Failed to upload image",
|
||||
"dropImages": "Drop images to attach"
|
||||
},
|
||||
"signIn": {
|
||||
"title": "Sign in to Claude",
|
||||
"done": "Signed in. New turns use these credentials — no restart needed.",
|
||||
"asking": "Asking the CLI for a sign-in link…",
|
||||
"openHint": "Open this, authorise, and paste back the code it gives you.",
|
||||
"authorise": "Authorise in claude.com",
|
||||
"copyLink": "Copy the link, to open it in another browser or on your phone",
|
||||
"pasteCode": "paste the code"
|
||||
},
|
||||
"message": {
|
||||
"copyCode": "Copy the code",
|
||||
"copyMarkdown": "Copy as a markdown block",
|
||||
"showMore": "Show more ({{count}} more lines)",
|
||||
"subagent": "Subagent",
|
||||
"outputError": "Output — error",
|
||||
"contextCleared": "Context cleared — nothing above this is in memory",
|
||||
"interrupted": "Interrupted by user",
|
||||
"agentRestarted": "Agent restarted — turn cut off",
|
||||
"retry": "Retry",
|
||||
"planUnchanged": "Plan mode unchanged",
|
||||
"approve": "Approve",
|
||||
"frontmatter": "Frontmatter",
|
||||
"systemPrompt": "System prompt",
|
||||
"reviseHint": "What should change? The agent revises the plan from this.",
|
||||
"waiting": "Waiting for a response",
|
||||
"narrationFailed": "The narration was generated but would not play.",
|
||||
"readAloudFailed": "Could not read this message aloud: {{reason}}",
|
||||
"compacted": "Context compacted",
|
||||
"compactedAuto": "Context compacted automatically",
|
||||
"answerSent": "Answer sent — waiting for the agent",
|
||||
"other": "Other..."
|
||||
},
|
||||
"composer": {
|
||||
"stop": "Stop",
|
||||
"sendIntoTurn": "Send these into the turn that is already running",
|
||||
"removeFromQueue": "Remove from queue",
|
||||
"send": "Send",
|
||||
"startHint": "Send a message to start",
|
||||
"jumpToLatest": "Jump to latest message",
|
||||
"noAudio": "No audio was captured"
|
||||
},
|
||||
"agent": {
|
||||
"gone": "This panel’s agent no longer exists.",
|
||||
"forget": "Forget it",
|
||||
"name": "Name this agent",
|
||||
"peers": "Peers: {{names}}"
|
||||
},
|
||||
"copy": "Copy"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,5 +315,114 @@
|
||||
"newChatTitle": "Novo chat",
|
||||
"renameHint": "Clique para renomear este chat",
|
||||
"hideHidden": "Ocultar ocultas"
|
||||
},
|
||||
"chat": {
|
||||
"newChat": "Novo chat",
|
||||
"rename": "Renomear",
|
||||
"deleteQ": "Eliminar “{{title}}”?",
|
||||
"duplicateAgent": "Já existe um agente chamado “{{name}}” neste painel",
|
||||
"composerPlaceholder": "Em que quer trabalhar agora?",
|
||||
"turnFinished": "Turno terminado · sem mensagem de fecho",
|
||||
"job": {
|
||||
"title": "Trabalho em segundo plano",
|
||||
"runsIn": "Executa em",
|
||||
"command": "Comando — executar exatamente isto",
|
||||
"prompt": "Prompt — descreva-o e o agente descobre como",
|
||||
"watch": "Vigiar algo",
|
||||
"watchHint": "Em texto corrido. O agente pergunta antes de armar se algo não estiver claro.",
|
||||
"handBack": "Sair e devolver ao agente, depois rearmar",
|
||||
"keepGoing": "Continuar — agir de cada vez e prosseguir",
|
||||
"stopAfter": "Parar depois de",
|
||||
"start": "Iniciar",
|
||||
"watchExample": "vigiar os nossos repositórios Gitea; em cada novo push, obtê-lo e enviar para o espelho no GitHub",
|
||||
"startJob": "Iniciar um trabalho em segundo plano ou um vigilante"
|
||||
},
|
||||
"tasks": {
|
||||
"restart": "Reiniciar",
|
||||
"pinned": "Fixado",
|
||||
"clearFinished": "Limpar todos os terminados",
|
||||
"clearFinishedTasks": "Limpar todas as tarefas terminadas",
|
||||
"stopJob": "Parar este trabalho",
|
||||
"dismiss": "Descartar",
|
||||
"rerun": "Pará-lo e executar o mesmo comando novamente",
|
||||
"collapse": "Recolher",
|
||||
"collapseDetail": "Recolher o detalhe da tarefa",
|
||||
"stopNamed": "Parar {{label}}",
|
||||
"dismissNamed": "Descartar {{label}}",
|
||||
"status": "Tarefa {{status}}."
|
||||
},
|
||||
"usage": {
|
||||
"noSignIn": "Sem sessão guardada — execute /login para usar esta",
|
||||
"updating": "A atualizar…",
|
||||
"unreadable": "Não foi possível ler o consumo nesta máquina.",
|
||||
"loading": "A carregar…",
|
||||
"limits": "Limites",
|
||||
"byDay": "Tokens por dia",
|
||||
"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}}"
|
||||
},
|
||||
"attach": {
|
||||
"image": "Imagem",
|
||||
"textFile": "Ficheiro de texto",
|
||||
"webpageUrl": "URL de página web",
|
||||
"attach": "Anexar",
|
||||
"readFailed": "Não foi possível ler {{name}}: {{reason}}",
|
||||
"unknownError": "erro desconhecido",
|
||||
"webpageTitle": "Anexar página web",
|
||||
"webpageHint": "Introduza um URL para extrair e anexar como contexto.",
|
||||
"scrapeFailed": "Falha ao extrair a página web",
|
||||
"uploadFailed": "Falha ao carregar a imagem",
|
||||
"dropImages": "Largue imagens para anexar"
|
||||
},
|
||||
"signIn": {
|
||||
"title": "Iniciar sessão no Claude",
|
||||
"done": "Sessão iniciada. Os novos turnos usam estas credenciais — não é preciso reiniciar.",
|
||||
"asking": "A pedir à CLI um link de início de sessão…",
|
||||
"openHint": "Abra isto, autorize e cole o código que lhe for dado.",
|
||||
"authorise": "Autorizar em claude.com",
|
||||
"copyLink": "Copiar o link, para o abrir noutro navegador ou no telemóvel",
|
||||
"pasteCode": "cole o código"
|
||||
},
|
||||
"message": {
|
||||
"copyCode": "Copiar o código",
|
||||
"copyMarkdown": "Copiar como bloco markdown",
|
||||
"showMore": "Mostrar mais ({{count}} linhas)",
|
||||
"subagent": "Subagente",
|
||||
"outputError": "Saída — erro",
|
||||
"contextCleared": "Contexto limpo — nada acima disto está em memória",
|
||||
"interrupted": "Interrompido pelo utilizador",
|
||||
"agentRestarted": "Agente reiniciado — turno cortado",
|
||||
"retry": "Tentar novamente",
|
||||
"planUnchanged": "Modo de plano inalterado",
|
||||
"approve": "Aprovar",
|
||||
"frontmatter": "Frontmatter",
|
||||
"systemPrompt": "Prompt de sistema",
|
||||
"reviseHint": "O que deve mudar? O agente revê o plano a partir disto.",
|
||||
"waiting": "À espera de resposta",
|
||||
"narrationFailed": "A narração foi gerada mas não foi possível reproduzi-la.",
|
||||
"readAloudFailed": "Não foi possível ler esta mensagem em voz alta: {{reason}}",
|
||||
"compacted": "Contexto compactado",
|
||||
"compactedAuto": "Contexto compactado automaticamente",
|
||||
"answerSent": "Resposta enviada — à espera do agente",
|
||||
"other": "Outro..."
|
||||
},
|
||||
"composer": {
|
||||
"stop": "Parar",
|
||||
"sendIntoTurn": "Enviar isto para o turno que já está a correr",
|
||||
"removeFromQueue": "Remover da fila",
|
||||
"send": "Enviar",
|
||||
"startHint": "Envie uma mensagem para começar",
|
||||
"jumpToLatest": "Ir para a mensagem mais recente",
|
||||
"noAudio": "Não foi captado áudio"
|
||||
},
|
||||
"agent": {
|
||||
"gone": "O agente deste painel já não existe.",
|
||||
"forget": "Esquecê-lo",
|
||||
"name": "Dar nome a este agente",
|
||||
"peers": "Pares: {{names}}"
|
||||
},
|
||||
"copy": "Copiar"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user