feat(webui): i18n main chat — chat + threadList domains

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-08-11 17:44:52 +08:00
parent ddbc6c808e
commit c18ca4693c
17 changed files with 1187 additions and 375 deletions
+28 -15
View File
@@ -29,8 +29,10 @@ import {
summarizeActionGroup,
} from "@/lib/actionGrouping";
import { mergeApprovalDecision } from "@/lib/approvalDecision";
import { formatToolLabel, toolArgumentPreview } from "@/lib/toolLabel";
import { formatToolLabelWith, toolArgumentPreview } from "@/lib/toolLabel";
import { autoApprovesTools, type ReviewMode } from "@/lib/reviewMode";
import { useTranslations } from "next-intl";
import { useToolLabels } from "@/app/hooks/useToolLabels";
import {
extractStringFromMessageContent,
extractSubAgentContent,
@@ -114,6 +116,8 @@ export const ActionGroup = React.memo<ActionGroupProps>(function ActionGroup({
compactionAnchorId,
summarizationEvent,
}) {
const t = useTranslations("chat");
const toolLabels = useToolLabels();
const [open, setOpen] = useState(() => !defaultCollapsed);
const [expandedSubAgents, setExpandedSubAgents] = useState<
Record<string, boolean>
@@ -234,7 +238,7 @@ export const ActionGroup = React.memo<ActionGroupProps>(function ActionGroup({
<div className="ml-2 mt-1 space-y-3 border-l border-border pl-3">
<div>
<div className="mb-1 text-[11px] font-semibold uppercase text-muted-foreground">
Input
{t("subAgent.input")}
</div>
<MarkdownContent
content={extractSubAgentContent(subAgent.input)}
@@ -244,7 +248,7 @@ export const ActionGroup = React.memo<ActionGroupProps>(function ActionGroup({
{steps.length > 0 && (
<div>
<div className="mb-1 text-[11px] font-semibold uppercase text-muted-foreground">
Steps
{t("subAgent.steps")}
</div>
<SubAgentSteps
steps={steps}
@@ -256,7 +260,7 @@ export const ActionGroup = React.memo<ActionGroupProps>(function ActionGroup({
{subAgent.output && (
<div>
<div className="mb-1 text-[11px] font-semibold uppercase text-muted-foreground">
Output
{t("subAgent.output")}
</div>
<MarkdownContent
content={extractSubAgentContent(subAgent.output)}
@@ -318,6 +322,7 @@ export const ActionGroup = React.memo<ActionGroupProps>(function ActionGroup({
stream,
subAgentSteps,
submittedActionRequestKeys,
t,
toggleSubAgent,
ui,
]
@@ -325,19 +330,21 @@ export const ActionGroup = React.memo<ActionGroupProps>(function ActionGroup({
const lastToolCall = summary.lastToolCall;
const lastToolLabel = lastToolCall
? formatToolLabel(lastToolCall.name, lastToolCall.args)
: "Working";
? formatToolLabelWith(toolLabels, lastToolCall.name, lastToolCall.args)
: t("actionGroup.working");
const lastToolPreview = lastToolCall
? toolArgumentPreview(lastToolCall.name, lastToolCall.args)
: "";
const actionNoun = summary.total === 1 ? "action" : "actions";
const primaryText = hasPendingApproval
? "Approval required"
? t("actionGroup.approvalRequired")
: summary.failed > 0
? `${summary.failed} of ${summary.total} ${actionNoun} failed`
? t("actionGroup.failed", {
failed: summary.failed,
total: summary.total,
})
: isStreaming || summary.pending > 0 || summary.interrupted > 0
? `Working · ${summary.total} ${actionNoun}`
: `${summary.total} ${actionNoun} completed`;
? t("actionGroup.workingWithCount", { total: summary.total })
: t("actionGroup.completed", { total: summary.total });
const headerText = `${primaryText} · ${lastToolLabel}${
lastToolPreview ? ` · ${lastToolPreview}` : ""
}`;
@@ -345,12 +352,16 @@ export const ActionGroup = React.memo<ActionGroupProps>(function ActionGroup({
return (
<section
className="my-2 overflow-hidden rounded-md border border-border bg-[var(--color-surface)]"
aria-label="Assistant work process"
aria-label={t("actionGroup.ariaLabel")}
>
<button
type="button"
aria-expanded={open}
aria-label={`${open ? "Collapse" : "Expand"} ${headerText}`}
aria-label={
open
? t("actionGroup.collapseGroup", { summary: headerText })
: t("actionGroup.expandGroup", { summary: headerText })
}
title={headerText}
onClick={() => setOpen((current) => !current)}
className="flex min-h-10 w-full items-center gap-2 px-3 py-2 text-left text-xs text-muted-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
@@ -446,13 +457,15 @@ export const ActionGroup = React.memo<ActionGroupProps>(function ActionGroup({
type="button"
onClick={() => setOpen(false)}
className="mt-1 flex min-h-8 w-full items-center justify-center gap-1.5 rounded-sm text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`Collapse ${summary.total} ${actionNoun}`}
aria-label={t("actionGroup.collapseCount", {
total: summary.total,
})}
>
<ChevronUp
aria-hidden="true"
className="size-3.5"
/>
Collapse
{t("actionGroup.collapse")}
</button>
</div>
)}
+10 -7
View File
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { MessageCircleQuestion } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
interface Choice {
value: string;
@@ -32,6 +33,8 @@ export function AskUserInterrupt({
onCancel,
isLoading,
}: AskUserInterruptProps) {
const t = useTranslations("chat");
const tCommon = useTranslations("common");
// answers[i] = final answer string; picked[i] = selected choice value or
// OTHER for multiple_choice (null for free-text questions).
const [answers, setAnswers] = useState<string[]>(() =>
@@ -62,7 +65,7 @@ export function AskUserInterrupt({
aria-hidden="true"
/>
<span className="text-xs font-semibold uppercase tracking-wider">
EvoScientist Needs Your Input
{t("askUser.title")}
</span>
</div>
@@ -114,13 +117,13 @@ export function AskUserInterrupt({
: "border-border bg-background hover:bg-accent"
)}
>
Other…
{t("askUser.other")}
</button>
{picked[i] === OTHER && (
<Textarea
value={answers[i]}
onChange={(e) => setAnswer(i, e.target.value)}
placeholder="Type your answer…"
placeholder={t("askUser.answerPlaceholder")}
className="text-sm"
rows={2}
disabled={isLoading}
@@ -133,8 +136,8 @@ export function AskUserInterrupt({
onChange={(e) => setAnswer(i, e.target.value)}
placeholder={
isOptional(q)
? "Type your answer… (optional)"
: "Type your answer…"
? t("askUser.answerPlaceholderOptional")
: t("askUser.answerPlaceholder")
}
className="text-sm"
rows={2}
@@ -152,7 +155,7 @@ export function AskUserInterrupt({
onClick={onCancel}
disabled={isLoading}
>
Cancel
{tCommon("cancel")}
</Button>
<Button
size="sm"
@@ -160,7 +163,7 @@ export function AskUserInterrupt({
disabled={!canSubmit || isLoading}
className="bg-[var(--brand-solid)] text-[var(--brand-foreground)] hover:opacity-90"
>
{isLoading ? "Submitting…" : "Submit"}
{isLoading ? t("askUser.submitting") : t("askUser.submit")}
</Button>
</div>
</div>
+225 -192
View File
@@ -115,6 +115,7 @@ import {
} from "@/app/hooks/useAvailableModels";
import { formatTokenCount, useUsage } from "@/app/hooks/useUsage";
import { UsageDetailsDialog } from "@/app/components/UsageDetailsDialog";
import { useTranslations } from "next-intl";
type DashboardNavTarget =
| {
@@ -142,44 +143,6 @@ interface ChatInterfaceProps {
onNotifyReady?: (notify: MainChatReporter | null) => void;
}
const SUGGESTED_PROMPTS = [
"Survey recent papers on a topic",
"Design an experiment plan",
"Analyze workspace files",
];
const REVIEW_MODE_OPTIONS: Array<{
mode: ReviewMode;
label: string;
description: string;
icon: typeof Hand;
activeClassName: string;
}> = [
{
mode: "manual",
label: "Manual review",
description: "Approve each tool action before it runs.",
icon: Hand,
activeClassName: "bg-[var(--brand-solid)] text-[var(--brand-foreground)]",
},
{
mode: "auto",
label: "Auto-approve",
description:
"Approve tool actions automatically; questions can still pause.",
icon: Zap,
activeClassName: "bg-amber-600 text-white hover:bg-amber-700",
},
{
mode: "full",
label: "Full approve",
description:
"Approve tool actions automatically and continue without questions.",
icon: ChevronsRight,
activeClassName: "bg-rose-700 text-white hover:bg-rose-800",
},
];
interface UploadedWorkspaceFile {
name: string;
path: string;
@@ -380,6 +343,43 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
onOpenThread,
workspaceOpen,
}) => {
const t = useTranslations("chat");
const tCommon = useTranslations("common");
const suggestedPrompts = [
t("suggestedSurvey"),
t("suggestedExperiment"),
t("suggestedFiles"),
];
const reviewModeOptions: Array<{
mode: ReviewMode;
label: string;
description: string;
icon: typeof Hand;
activeClassName: string;
}> = [
{
mode: "manual",
label: t("reviewMode.manual.label"),
description: t("reviewMode.manual.description"),
icon: Hand,
activeClassName:
"bg-[var(--brand-solid)] text-[var(--brand-foreground)]",
},
{
mode: "auto",
label: t("reviewMode.auto.label"),
description: t("reviewMode.auto.description"),
icon: Zap,
activeClassName: "bg-amber-600 text-white hover:bg-amber-700",
},
{
mode: "full",
label: t("reviewMode.full.label"),
description: t("reviewMode.full.description"),
icon: ChevronsRight,
activeClassName: "bg-rose-700 text-white hover:bg-rose-800",
},
];
const [metaOpen, setMetaOpen] = useState<"tasks" | "files" | null>(null);
const tasksContainerRef = useRef<HTMLDivElement | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
@@ -522,10 +522,12 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
usage.status.availability === "offline" ||
usage.status.availability === "unavailable";
const usageLabel = usage.loading
? "Tokens loading"
? t("usage.tokensLoading")
: usageUnavailable || !usage.summary
? "Tokens unavailable"
: `${formatTokenCount(usage.summary.total_tokens)} tokens`;
? t("usage.tokensUnavailable")
: t("usage.tokensLabel", {
count: formatTokenCount(usage.summary.total_tokens),
});
// Count of background async sub-agents (writing / data-analysis) still
// running — drives the composer's "agents running" pulse. We poll each task's
@@ -617,9 +619,9 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
// the user's own queue has drained.
if (queuedMessages.length > 0) return;
const reportedKeys = getThreadAutoNotifyReportedKeys(threadId);
for (const t of liveAgentTasks) {
if (!isTerminalStatus(t.liveStatus)) continue;
const key = asyncTaskReportKey(t);
for (const task of liveAgentTasks) {
if (!isTerminalStatus(task.liveStatus)) continue;
const key = asyncTaskReportKey(task);
if (reportedKeys.has(key)) continue;
if (
messages.some(
@@ -627,7 +629,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
message.type === "human" &&
asyncUpdateMatchesTask(
extractStringFromMessageContent(message),
t
task
)
)
) {
@@ -636,9 +638,9 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
}
autoFireInFlightRef.current = true;
markThreadAutoNotifyReported(threadId, key);
sendMessage(formatAsyncUpdateMessage(t), reviewMode);
sendMessage(formatAsyncUpdateMessage(task), reviewMode);
toast.success(
`Auto-reported ${agentLabel(t.agent_name)} to the main chat.`
t("agents.autoReported", { agent: agentLabel(task.agent_name) })
);
break; // one per idle window; the rest fire once this turn settles
}
@@ -649,6 +651,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
input,
messages,
sendMessage,
t,
threadId,
queuedMessages,
reviewMode,
@@ -953,17 +956,17 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
applyReviewMode(mode);
toast.info(
mode === "auto"
? "Auto-approve enabled for this chat."
: "Manual review enabled for this chat."
? t("reviewMode.autoEnabled")
: t("reviewMode.manualEnabled")
);
},
[applyReviewMode, reviewMode, reviewModeLocked]
[applyReviewMode, reviewMode, reviewModeLocked, t]
);
const confirmFullApprove = useCallback(() => {
applyReviewMode("full");
toast.info("Full approve enabled for this chat.");
}, [applyReviewMode]);
toast.info(t("reviewMode.fullEnabled"));
}, [applyReviewMode, t]);
// Follow the thread and migrate the pending New Chat mode only when the first
// submitted message created this thread.
@@ -1033,21 +1036,17 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
}
setPendingFiles((currentFiles) => [...currentFiles, ...data.files!]);
notifyWorkspaceChanged(uploadThreadId);
toast.success(
`${data.files.length} file${
data.files.length === 1 ? "" : "s"
} uploaded to the workspace.`
);
toast.success(t("upload.uploaded", { count: data.files.length }));
} catch (error) {
errorToast(
"chat.attachment",
error instanceof Error ? error.message : "Failed to upload files."
error instanceof Error ? error.message : t("upload.failed")
);
} finally {
setIsUploadingFiles(false);
}
},
[ensureThread, threadId]
[ensureThread, t, threadId]
);
const removePendingFile = useCallback((filePath: string) => {
@@ -1139,10 +1138,8 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
if (!target || messages[0]?.id === id) return messages;
return [target, ...messages.filter((message) => message.id !== id)];
});
toast.info(
"This message will run next without interrupting the current turn."
);
}, []);
toast.info(t("queue.steerToast"));
}, [t]);
const moveQueuedMessage = useCallback((id: number, direction: -1 | 1) => {
setQueuedMessages((messages) => {
@@ -1536,10 +1533,9 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Enable Full approve?</DialogTitle>
<DialogTitle>{t("reviewMode.fullApprove.title")}</DialogTitle>
<DialogDescription>
EvoScientist will approve reviewed tool actions automatically
and will not pause to ask you for clarification.
{t("reviewMode.fullApprove.description")}
</DialogDescription>
</DialogHeader>
<div className="flex items-start gap-3 rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-950 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-100">
@@ -1547,24 +1543,20 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
className="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p>
When details are missing, the model will make reasonable,
conservative assumptions and continue. This setting applies only
to the current research.
</p>
<p>{t("reviewMode.fullApprove.warning")}</p>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setFullApproveDialogOpen(false)}
>
Cancel
{tCommon("cancel")}
</Button>
<Button
onClick={confirmFullApprove}
className="bg-amber-600 text-white hover:bg-amber-700"
>
Enable Full approve
{t("reviewMode.fullApprove.confirm")}
</Button>
</DialogFooter>
</DialogContent>
@@ -1593,28 +1585,31 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Choose model</DialogTitle>
<DialogTitle>{t("modelPicker.title")}</DialogTitle>
<DialogDescription>
Saved for this chat; the next message freezes it into the
run&rsquo;s snapshot.
{t("modelPicker.description")}
</DialogDescription>
</DialogHeader>
<div className="mt-2 space-y-2">
<div className="text-xs text-muted-foreground">
Current:{" "}
{t("modelPicker.current")}{" "}
<span className="font-mono">
{modelSelection !== "inherit"
? modelRefLabel(modelSelection.primary)
: currentModel
? `${currentModel.name}${
currentModel.provider
? ` (${currentModel.provider})`
: ""
} · default`
: "registry default"}
? t("modelPicker.currentDefault", {
model: `${currentModel.name}${
currentModel.provider
? ` (${currentModel.provider})`
: ""
}`,
})
: t("modelPicker.registryDefault")}
</span>
{modelRegistryLoading && (
<span className="ml-2 italic">loading models…</span>
<span className="ml-2 italic">
{t("modelPicker.loadingModels")}
</span>
)}
</div>
{modelRegistryUnavailable && (
@@ -1626,8 +1621,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
{"\u26A0"}
</span>
<span>
The deployment&rsquo;s selectable model list didn&rsquo;t
load
{t("modelPicker.unavailable")}
{modelRegistryError ? ` (${modelRegistryError})` : ""}.
</span>
</div>
@@ -1636,7 +1630,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
type="text"
value={modelSearch}
onChange={(e) => setModelSearch(e.target.value)}
placeholder="Filter by name or provider…"
placeholder={t("modelPicker.filterPlaceholder")}
autoFocus
className="w-full rounded-md border border-border bg-background px-3 py-1.5 font-mono text-sm placeholder:font-sans placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
@@ -1644,8 +1638,8 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
{filteredPickerModels.length === 0 && (
<li className="px-3 py-2 text-xs text-muted-foreground">
{modelSearch
? `No models match "${modelSearch}".`
: "No models are enabled."}
? t("modelPicker.noMatch", { query: modelSearch })
: t("modelPicker.noneEnabled")}
</li>
)}
{filteredPickerModels.map((m) => {
@@ -1674,14 +1668,18 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
await setModelSelection({
primary: ref,
});
toast.success(`Model set to ${modelKey}.`);
toast.success(
t("modelPicker.setSuccess", { model: modelKey })
);
setModelPickerOpen(false);
} catch (err) {
errorToast(
"chat.message",
err instanceof Error
? `Couldn't update model: ${err.message}`
: "Couldn't update model — try again."
? t("modelPicker.updateFailed", {
message: err.message,
})
: t("modelPicker.updateFailedGeneric")
);
}
}}
@@ -1707,22 +1705,26 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
onClick={async () => {
try {
await setModelSelection("inherit");
toast.success("Model reset to the registry default.");
toast.success(t("modelPicker.resetSuccess"));
setModelPickerOpen(false);
} catch (err) {
errorToast(
"chat.message",
err instanceof Error
? `Couldn't reset model: ${err.message}`
: "Couldn't reset model — try again."
? t("modelPicker.resetFailed", {
message: err.message,
})
: t("modelPicker.resetFailedGeneric")
);
}
}}
disabled={modelSelection === "inherit"}
>
Reset to default
{t("modelPicker.resetToDefault")}
</Button>
<Button onClick={() => setModelPickerOpen(false)}>
{tCommon("close")}
</Button>
<Button onClick={() => setModelPickerOpen(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -1736,7 +1738,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
>
{isThreadLoading ? (
<div className="flex items-center justify-center p-8">
<p className="text-muted-foreground">Loading…</p>
<p className="text-muted-foreground">{tCommon("loading")}</p>
</div>
) : (
<>
@@ -1749,14 +1751,13 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
/>
</div>
<h2 className="text-pretty text-2xl font-semibold tracking-tight sm:text-3xl">
Where research evolves
{t("emptyStateTitle")}
</h2>
<p className="mt-2 max-w-lg text-sm text-muted-foreground">
Your self-evolving lab partner — reads the literature,
runs experiments, and remembers what matters.
{t("emptyStateSubtitle")}
</p>
<div className="mt-4 flex max-w-2xl flex-wrap justify-center gap-2">
{SUGGESTED_PROMPTS.map((prompt) => (
{suggestedPrompts.map((prompt) => (
<button
key={prompt}
type="button"
@@ -1891,7 +1892,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
<div className="flex-shrink-0 bg-background">
{queuedMessages.length > 0 && (
<div
aria-label="Queued messages"
aria-label={t("queue.label")}
className="relative mx-auto -mb-4 w-[calc(100%-16px)] max-w-[960px] px-2 sm:w-[calc(100%-24px)]"
>
<div className="rounded-b-lg rounded-t-2xl border border-border bg-background pb-5 pt-1 shadow-sm">
@@ -1932,10 +1933,11 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
moveQueuedMessage(q.id, 1);
}
}}
aria-label={`Reorder queued message ${index + 1} of ${
queuedMessages.length
}`}
title="Drag to reorder, or use the arrow keys"
aria-label={t("queue.reorderAria", {
index: index + 1,
total: queuedMessages.length,
})}
title={t("queue.reorderTitle")}
className="mt-0.5 inline-flex size-5 shrink-0 cursor-grab items-center justify-center rounded text-muted-foreground/60 focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing"
>
<GripVertical
@@ -1957,8 +1959,9 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
className="size-3 shrink-0"
aria-hidden="true"
/>
{q.files.length} file
{q.files.length === 1 ? "" : "s"}
{t("queue.filesAttached", {
count: q.files.length,
})}
</span>
)}
</div>
@@ -1966,21 +1969,23 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
<button
type="button"
onClick={() => steerQueuedMessage(q.id)}
aria-label="Steer with this message next"
title="Submit next without interrupting the current turn"
aria-label={t("queue.steerAria")}
title={t("queue.steerTitle")}
className="inline-flex h-7 items-center gap-1 rounded-full px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<CornerDownRight
className="size-3.5"
aria-hidden="true"
/>
<span className="hidden sm:inline">Steer</span>
<span className="hidden sm:inline">
{t("queue.steer")}
</span>
</button>
<button
type="button"
onClick={() => removeQueuedMessage(q.id)}
aria-label="Remove queued message"
title="Remove from queue"
aria-label={t("queue.removeAria")}
title={t("queue.removeTitle")}
className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<Trash2
@@ -2007,8 +2012,8 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
}}
>
<summary
aria-label="More queued message actions"
title="More actions"
aria-label={t("queue.moreAria")}
title={t("queue.moreTitle")}
className="inline-flex size-7 cursor-pointer list-none items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring [&::-webkit-details-marker]:hidden"
>
<Ellipsis
@@ -2027,7 +2032,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
className="size-4"
aria-hidden="true"
/>
Edit message
{t("editMessage")}
</button>
<button
type="button"
@@ -2038,7 +2043,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
className="size-4"
aria-hidden="true"
/>
Close queue
{t("queue.closeQueue")}
</button>
</div>
</details>
@@ -2093,7 +2098,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
key="label"
className="ml-[1px] min-w-0 truncate text-sm"
>
All tasks completed
{t("tasks.allCompleted")}
</span>,
];
}
@@ -2107,9 +2112,12 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
key="label"
className="ml-[1px] min-w-0 truncate text-sm"
>
Task{" "}
{totalTasks - groupedTodos.pending.length}{" "}
of {totalTasks}
{t("tasks.progress", {
current:
totalTasks -
groupedTodos.pending.length,
total: totalTasks,
})}
</span>,
<span
key="content"
@@ -2130,9 +2138,11 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
key="label"
className="ml-[1px] min-w-0 truncate text-sm"
>
Task{" "}
{totalTasks - groupedTodos.pending.length} of{" "}
{totalTasks}
{t("tasks.progress", {
current:
totalTasks - groupedTodos.pending.length,
total: totalTasks,
})}
</span>,
];
})()}
@@ -2154,7 +2164,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
aria-expanded={metaOpen === "files"}
>
<FileIcon size={16} />
Files (State)
{t("tasks.filesState")}
<span className="h-4 min-w-4 rounded-full bg-[var(--brand-solid)] px-0.5 text-center text-[10px] leading-[16px] text-[var(--brand-foreground)]">
{Object.keys(files).length}
</span>
@@ -2188,7 +2198,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
}
aria-expanded={metaOpen === "tasks"}
>
Tasks
{t("tasks.tasks")}
</button>
)}
{hasFiles && (
@@ -2202,14 +2212,14 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
}
aria-expanded={metaOpen === "files"}
>
Files (State)
{t("tasks.filesState")}
<span className="h-4 min-w-4 rounded-full bg-[var(--brand-solid)] px-0.5 text-center text-[10px] leading-[16px] text-[var(--brand-foreground)]">
{Object.keys(files).length}
</span>
</button>
)}
<button
aria-label="Close"
aria-label={tCommon("close")}
className="flex-1"
onClick={() => setMetaOpen(null)}
/>
@@ -2229,9 +2239,9 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
<h3 className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-tertiary">
{
{
pending: "Pending",
in_progress: "In Progress",
completed: "Completed",
pending: t("tasks.pending"),
in_progress: t("tasks.inProgress"),
completed: t("tasks.completed"),
}[status]
}
</h3>
@@ -2272,8 +2282,8 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
<button
type="button"
onClick={() => setModelPickerOpen(true)}
title="Click to change model for this chat"
aria-label="Change model for this chat"
title={t("modelPicker.changeTitle")}
aria-label={t("modelPicker.changeAria")}
className="-mx-1 flex items-center gap-1.5 rounded px-1 py-0.5 transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Sparkles
@@ -2305,15 +2315,15 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
});
toast.success(
effort === null
? "Reasoning effort back to the registry default."
: `Reasoning effort set to ${effort}.`
? t("reasoning.reset")
: t("reasoning.set", { effort })
);
} catch (err) {
errorToast(
"chat.model",
err instanceof Error
? `Couldn't update reasoning effort: ${err.message}`
: "Couldn't update reasoning effort — try again."
? t("reasoning.failed", { message: err.message })
: t("reasoning.failedGeneric")
);
}
}}
@@ -2340,17 +2350,21 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
});
toast.success(
value === null
? "Sampling back to the registry default."
? t("sampling.reset")
: value.kind === "temperature"
? `Temperature set to ${value.value.toFixed(2)} (top_p omitted).`
: `Top P set to ${value.value.toFixed(2)} (temperature omitted).`
? t("sampling.temperatureSet", {
value: value.value.toFixed(2),
})
: t("sampling.topPSet", {
value: value.value.toFixed(2),
})
);
} catch (err) {
errorToast(
"chat.model",
err instanceof Error
? `Couldn't update sampling: ${err.message}`
: "Couldn't update sampling — try again."
? t("sampling.failed", { message: err.message })
: t("sampling.failedGeneric")
);
}
}}
@@ -2367,7 +2381,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
type="button"
onClick={() => setUsageDialogOpen(true)}
title={usageLabel}
aria-label={`View token usage: ${usageLabel}`}
aria-label={t("usage.viewAria", { label: usageLabel })}
className={cn(
"flex shrink-0 items-center gap-1.5 rounded px-1.5 py-0.5 transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
currentModel && "ml-auto",
@@ -2387,19 +2401,19 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
<button
type="button"
onClick={onShowAgents}
title={`${runningAgents} background agent${
runningAgents === 1 ? "" : "s"
} running — click to view`}
aria-label={`${runningAgents} background agent${
runningAgents === 1 ? "" : "s"
} running — view`}
title={t("agents.runningTitle", {
count: runningAgents,
})}
aria-label={t("agents.runningAria", {
count: runningAgents,
})}
className="flex shrink-0 items-center gap-1.5 rounded px-1.5 py-0.5 font-medium text-[var(--brand)] transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span
className="size-2 animate-pulse rounded-full bg-[var(--color-warning)]"
aria-hidden="true"
/>
{runningAgents} agent{runningAgents === 1 ? "" : "s"}
{t("agents.runningLabel", { count: runningAgents })}
</button>
)}
</div>
@@ -2410,7 +2424,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
>
{pendingFiles.length > 0 && (
<div
aria-label="Attached files"
aria-label={t("upload.attachedFiles")}
className="flex flex-wrap gap-2 border-b border-border px-3 py-2"
>
{pendingFiles.map((file) => (
@@ -2431,8 +2445,12 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
<button
type="button"
onClick={() => removePendingFile(file.path)}
aria-label={`Remove ${file.name} from this message`}
title={`Remove ${file.name} from this message`}
aria-label={t("upload.removeAttachment", {
name: file.name,
})}
title={t("upload.removeAttachment", {
name: file.name,
})}
className="rounded-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<X
@@ -2449,16 +2467,16 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
aria-label="Message"
aria-label={t("messageInputLabel")}
disabled={hasPendingInterrupt}
placeholder={
hasPendingInterrupt
? "Respond to the request above to continue…"
? t("placeholderRespondToInterrupt")
: isReconnecting
? "Reconnecting to the running task…"
? t("placeholderReconnecting")
: isLoading
? "Queue a follow-up — sends when this turn finishes…"
: "Ask EvoScientist anything…"
? t("placeholderQueueFollowUp")
: t("placeholderDefault")
}
className="font-inherit field-sizing-content flex-1 resize-none border-0 bg-transparent px-3.5 pb-2.5 pt-3 text-sm leading-6 text-primary outline-none placeholder:text-tertiary disabled:cursor-not-allowed sm:px-4"
rows={1}
@@ -2481,8 +2499,8 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
disabled={
!assistant || hasPendingInterrupt || isUploadingFiles
}
aria-label="Upload files to workspace"
title="Upload files to workspace (max 50 MB each)"
aria-label={t("upload.aria")}
title={t("upload.title")}
className="inline-flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
<Paperclip
@@ -2492,10 +2510,10 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
</button>
<div
role="radiogroup"
aria-label="Review mode"
aria-label={t("reviewMode.label")}
className="inline-flex h-8 items-center rounded-md border border-border bg-muted/40 p-0.5"
>
{REVIEW_MODE_OPTIONS.map((option) => {
{reviewModeOptions.map((option) => {
const Icon = option.icon;
const active = reviewMode === option.mode;
return (
@@ -2527,12 +2545,16 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
<button
type="button"
onClick={() => onNavigate({ view: "workspace" })}
title={`${
workspaceOpen ? "Close" : "Open"
} conversation files`}
aria-label={`${
workspaceOpen ? "Close" : "Open"
} conversation files`}
title={
workspaceOpen
? t("workspace.closeFiles")
: t("workspace.openFiles")
}
aria-label={
workspaceOpen
? t("workspace.closeFiles")
: t("workspace.openFiles")
}
aria-pressed={Boolean(workspaceOpen)}
className="inline-flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
@@ -2541,7 +2563,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
aria-hidden="true"
/>
<span className="hidden max-w-[140px] truncate font-mono sm:inline lg:max-w-[220px]">
Files
{t("workspace.files")}
</span>
</button>
)}
@@ -2555,17 +2577,19 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
!isLoading &&
(submitDisabled || isUploadingFiles || !input.trim())
}
aria-label={isLoading ? "Stop generating" : "Send message"}
aria-label={
isLoading ? t("stopGenerating") : t("sendMessage")
}
>
{isLoading ? (
<>
<Square size={14} />
<span className="hidden sm:inline">Stop</span>
<span className="hidden sm:inline">{t("stop")}</span>
</>
) : (
<>
<ArrowUp size={18} />
<span className="hidden sm:inline">Send</span>
<span className="hidden sm:inline">{t("send")}</span>
</>
)}
</Button>
@@ -2597,6 +2621,13 @@ function ReasoningEffortSlider({
value: ReasoningEffort | null;
onCommit: (effort: ReasoningEffort | null) => void | Promise<void>;
}) {
const t = useTranslations("chat");
const effortLabels: Record<ReasoningEffort, string> = {
auto: t("reasoning.auto"),
low: t("reasoning.low"),
medium: t("reasoning.medium"),
high: t("reasoning.high"),
};
const committedIndex =
value && value !== "auto" ? EFFORT_LEVELS.indexOf(value) : 0;
const [draft, setDraft] = useState<number | null>(null);
@@ -2614,15 +2645,13 @@ function ReasoningEffortSlider({
<span
className={cn("flex items-center gap-1.5", disabled && "opacity-50")}
title={
disabled
? "Pick a model to override the registry-configured effort"
: "Reasoning effort for the primary model"
disabled ? t("reasoning.titleDisabled") : t("reasoning.title")
}
>
<input
type="range"
aria-label="Reasoning effort"
aria-valuetext={shown}
aria-label={t("reasoning.aria")}
aria-valuetext={effortLabels[shown]}
disabled={disabled}
min={0}
max={EFFORT_LEVELS.length - 1}
@@ -2652,7 +2681,7 @@ function ReasoningEffortSlider({
: "font-medium text-foreground"
)}
>
{shown}
{effortLabels[shown]}
</span>
</span>
);
@@ -2679,6 +2708,7 @@ function GenerationParamSlider({
step: number;
onCommit: (value: number | null) => void | Promise<void>;
}) {
const t = useTranslations("chat");
const anchor = override ?? fallback ?? min;
const [draft, setDraft] = useState<number | null>(null);
const [editing, setEditing] = useState(false);
@@ -2725,10 +2755,13 @@ function GenerationParamSlider({
)}
title={
disabled
? `Pick a model to override the registry-configured ${ariaLabel}`
? t("sampling.sliderTitleDisabled", { label: ariaLabel })
: override === null
? `${ariaLabel}: registry default ${fallback ?? "unset"} — drag to override or click the value to type one`
: `${ariaLabel} override — click the value to type an exact one; empty + Enter resets`
? t("sampling.sliderTitleDefault", {
label: ariaLabel,
value: fallback ?? t("sampling.unset"),
})
: t("sampling.sliderTitleOverride", { label: ariaLabel })
}
>
<input
@@ -2760,7 +2793,7 @@ function GenerationParamSlider({
<input
type="text"
inputMode="decimal"
aria-label={`${ariaLabel} exact value`}
aria-label={t("sampling.exactValue", { label: ariaLabel })}
autoFocus
value={editText}
onChange={(event) => setEditText(event.target.value)}
@@ -2782,7 +2815,7 @@ function GenerationParamSlider({
type="button"
disabled={disabled}
onClick={startEdit}
title={`Click to type an exact ${ariaLabel} value`}
title={t("sampling.clickToType", { label: ariaLabel })}
className={cn(
"w-12 text-left text-xs tabular-nums",
isOverride
@@ -2812,6 +2845,7 @@ function SamplingOverridePicker({
fallbackTopP: number | null;
onCommit: (value: SamplingOverride | null) => void | Promise<void>;
}) {
const t = useTranslations("chat");
const mode: "default" | "temperature" | "top_p" = override?.kind ?? "default";
const select = (next: "default" | "temperature" | "top_p") => {
@@ -2832,7 +2866,7 @@ function SamplingOverridePicker({
};
const modes = [
{ key: "default", label: "默认" },
{ key: "default", label: t("sampling.modeDefault") },
{ key: "temperature", label: "T" },
{ key: "top_p", label: "P" },
] as const;
@@ -2841,9 +2875,7 @@ function SamplingOverridePicker({
<span
className={cn("flex items-center gap-1.5", disabled && "opacity-50")}
title={
disabled
? "Pick a model to override the registry-configured sampling"
: "Sampling override: temperature and top_p are mutually exclusive"
disabled ? t("sampling.titleDisabled") : t("sampling.title")
}
>
{/* ≥sm: segmented control */}
@@ -2868,7 +2900,7 @@ function SamplingOverridePicker({
</span>
{/* <sm: dropdown */}
<select
aria-label="Sampling override"
aria-label={t("sampling.aria")}
disabled={disabled}
value={mode}
onChange={(event) =>
@@ -2884,7 +2916,7 @@ function SamplingOverridePicker({
</select>
{mode === "temperature" && (
<GenerationParamSlider
ariaLabel="Temperature"
ariaLabel={t("sampling.temperature")}
disabled={disabled}
override={override?.kind === "temperature" ? override.value : null}
fallback={fallbackTemperature}
@@ -2898,7 +2930,7 @@ function SamplingOverridePicker({
)}
{mode === "top_p" && (
<GenerationParamSlider
ariaLabel="Top P"
ariaLabel={t("sampling.topP")}
disabled={disabled}
override={override?.kind === "top_p" ? override.value : null}
fallback={fallbackTopP}
@@ -2915,6 +2947,7 @@ function SamplingOverridePicker({
}
function ThinkingTimer() {
const t = useTranslations("chat");
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const startedAt = Date.now();
@@ -2935,7 +2968,7 @@ function ThinkingTimer() {
aria-hidden="true"
className="size-2 animate-pulse rounded-full bg-[var(--brand)]"
/>
<span>思考中…</span>
<span>{t("thinkingTimer")}</span>
<span className="tabular-nums">
{mm}:{ss}
</span>
+16 -10
View File
@@ -32,6 +32,7 @@ import { mapActionRequestsToToolCalls } from "@/lib/actionGrouping";
import { mergeApprovalDecision } from "@/lib/approvalDecision";
import { autoApprovesTools, type ReviewMode } from "@/lib/reviewMode";
import { errorToast } from "@/lib/errorReporter";
import { useTranslations } from "next-intl";
interface ChatMessageProps {
message: Message;
@@ -75,6 +76,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
subAgentSteps,
threadId,
}) => {
const t = useTranslations("chat");
const isUser = message.type === "human";
const messageContent = extractStringFromMessageContent(message);
const hasContent = messageContent && messageContent.trim() !== "";
@@ -185,9 +187,9 @@ export const ChatMessage = React.memo<ChatMessageProps>(
if (copyResetTimer.current) clearTimeout(copyResetTimer.current);
copyResetTimer.current = setTimeout(() => setCopied(false), 2000);
} else {
errorToast("clipboard", "Couldn't copy to clipboard.");
errorToast("clipboard", t("message.copyFailed"));
}
}, [messageContent]);
}, [messageContent, t]);
const [expandedSubAgents, setExpandedSubAgents] = useState<
Record<string, boolean>
>({});
@@ -218,7 +220,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
className="size-3 text-[var(--brand)]"
aria-hidden="true"
/>
Background agent reported back
{t("message.backgroundAgentReported")}
</span>
</div>
);
@@ -256,7 +258,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
className="h-3.5 w-3.5"
aria-hidden="true"
/>
Thinking
{t("message.thinking")}
</button>
{thinkingOpen && (
<div className="mt-2 whitespace-pre-wrap break-words border-l-2 border-border pl-3 text-sm leading-relaxed text-muted-foreground">
@@ -299,7 +301,9 @@ export const ChatMessage = React.memo<ChatMessageProps>(
<button
type="button"
onClick={handleCopy}
aria-label={copied ? "Copied" : "Copy message"}
aria-label={
copied ? t("message.copied") : t("message.copy")
}
className="inline-flex items-center rounded p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
{copied ? (
@@ -321,7 +325,9 @@ export const ChatMessage = React.memo<ChatMessageProps>(
<button
type="button"
onClick={handleCopy}
aria-label={copied ? "Copied" : "Copy message"}
aria-label={
copied ? t("message.copied") : t("message.copy")
}
className="inline-flex items-center rounded p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
{copied ? (
@@ -339,7 +345,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
<button
type="button"
onClick={() => onEditMessage?.(messageContent)}
aria-label="Edit message"
aria-label={t("editMessage")}
className="inline-flex items-center rounded p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<Pencil
@@ -421,7 +427,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
<div className="w-full max-w-full">
<div className="border-border-light rounded-md border bg-[var(--color-surface)] p-4">
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">
Input
{t("subAgent.input")}
</h4>
<div className="mb-4">
<MarkdownContent
@@ -432,7 +438,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
{(subAgentSteps?.[subAgent.id]?.length ?? 0) > 0 && (
<>
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">
Steps
{t("subAgent.steps")}
</h4>
<div className="mb-4">
<SubAgentSteps
@@ -445,7 +451,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
{subAgent.output && (
<>
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">
Output
{t("subAgent.output")}
</h4>
<MarkdownContent
content={extractSubAgentContent(subAgent.output)}
+25 -14
View File
@@ -5,6 +5,7 @@ import { ChevronRight, Layers } from "lucide-react";
import { MarkdownContent } from "@/app/components/MarkdownContent";
import { extractSummaryBody } from "@/lib/summarization";
import { cn } from "@/lib/utils";
import { useTranslations, useFormatter } from "next-intl";
interface CompactionSummaryProps {
/** Raw summary message content (wrapper + body). */
@@ -19,8 +20,11 @@ interface CompactionSummaryProps {
budgetHardTokens?: number | null;
}
function formatTokens(value: number): string {
return value.toLocaleString("en-US");
function formatTokens(
format: ReturnType<typeof useFormatter>,
value: number
): string {
return format.number(value);
}
/**
@@ -37,6 +41,8 @@ export const CompactionSummary = React.memo<CompactionSummaryProps>(
estimatedTokensAfter,
budgetHardTokens,
}) => {
const t = useTranslations("chat");
const format = useFormatter();
const [open, setOpen] = useState(false);
const body = extractSummaryBody(content);
const hasTokenStats =
@@ -57,17 +63,19 @@ export const CompactionSummary = React.memo<CompactionSummaryProps>(
className="size-3.5 text-[var(--brand)]"
aria-hidden="true"
/>
Conversation compacted
{t("compaction.title")}
{typeof summarizedCount === "number" && summarizedCount > 0 && (
<span className="text-muted-foreground/70">
· {summarizedCount} earlier{" "}
{summarizedCount === 1 ? "message" : "messages"} summarized
· {t("compaction.summarized", { count: summarizedCount })}
</span>
)}
{hasTokenStats && (
<span className="text-muted-foreground/70">
· ≈{formatTokens(estimatedTokensBefore)} → ≈
{formatTokens(estimatedTokensAfter)} tokens
·{" "}
{t("compaction.tokenStats", {
before: formatTokens(format, estimatedTokensBefore),
after: formatTokens(format, estimatedTokensAfter),
})}
</span>
)}
<ChevronRight
@@ -85,13 +93,16 @@ export const CompactionSummary = React.memo<CompactionSummaryProps>(
<MarkdownContent content={body} />
{hasTokenStats && (
<p className="mt-3 border-t border-border/60 pt-2 text-[11px] text-muted-foreground/80">
Prompt size (estimate): ≈{formatTokens(estimatedTokensBefore)}{" "}
tokens before compaction, ≈{formatTokens(estimatedTokensAfter)}{" "}
after
{typeof budgetHardTokens === "number" &&
` · hard message budget ≈${formatTokens(budgetHardTokens)} tokens`}
. Exact usage appears in token consumption once the next model
call completes.
{typeof budgetHardTokens === "number"
? t("compaction.detailsWithBudget", {
before: formatTokens(format, estimatedTokensBefore),
after: formatTokens(format, estimatedTokensAfter),
budget: formatTokens(format, budgetHardTokens),
})
: t("compaction.detailsWithoutBudget", {
before: formatTokens(format, estimatedTokensBefore),
after: formatTokens(format, estimatedTokensAfter),
})}
</p>
)}
</div>
+15 -9
View File
@@ -7,6 +7,7 @@ import {
type ContextUsageEvent,
} from "@/lib/contextUsage";
import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
/**
* Live context-occupancy pill.
@@ -22,6 +23,7 @@ export const ContextUsageIndicator = React.memo<{
usage: ContextUsageEvent | null;
measuredTokens: number | null;
}>(function ContextUsageIndicator({ usage, measuredTokens }) {
const t = useTranslations("chat");
if (!usage) return null;
const estimated = usage.usedTokens + usage.reservedTokens;
const occupied = measuredTokens ?? estimated;
@@ -29,16 +31,20 @@ export const ContextUsageIndicator = React.memo<{
const percent = Math.max(0, Math.round((occupied / limit) * 100));
const nearlyFull = occupied >= limit * 0.85;
const label = `ctx ${percent}% · ${formatCompactTokens(
occupied
)}/${formatCompactTokens(limit)}`;
const label = t("contextUsage.label", {
percent,
used: formatCompactTokens(occupied),
limit: formatCompactTokens(limit),
});
const tooltipParams = {
used: occupied.toLocaleString(),
limit: limit.toLocaleString(),
soft: usage.softTokens.toLocaleString(),
};
const tooltip =
(measuredTokens !== null
? `Last model call used ${occupied.toLocaleString()} of ` +
`${limit.toLocaleString()} tokens (measured)`
: `Context used (estimate): ${occupied.toLocaleString()} of ` +
`${limit.toLocaleString()} tokens`) +
` · auto-compact messages at ≈${usage.softTokens.toLocaleString()}.`;
measuredTokens !== null
? t("contextUsage.tooltipMeasured", tooltipParams)
: t("contextUsage.tooltipEstimate", tooltipParams);
return (
<span
+3 -1
View File
@@ -1,6 +1,7 @@
"use client";
import React from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Bot, ChevronDown, ChevronUp, Loader2 } from "lucide-react";
import type { SubAgent } from "@/app/types/types";
@@ -13,6 +14,7 @@ interface SubAgentIndicatorProps {
export const SubAgentIndicator = React.memo<SubAgentIndicatorProps>(
({ subAgent, onClick, isExpanded = true }) => {
const t = useTranslations("chat");
const running = subAgent.status === "pending";
return (
<div className="w-fit max-w-[70vw] overflow-hidden rounded-lg bg-card">
@@ -39,7 +41,7 @@ export const SubAgentIndicator = React.memo<SubAgentIndicatorProps>(
{subAgent.subAgentName}
</span>
<span className="text-xs font-normal text-muted-foreground">
subagent
{t("subAgent.badge")}
</span>
{isExpanded ? (
<ChevronUp
+103 -66
View File
@@ -31,7 +31,6 @@ import {
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { formatTime, formatFullTime } from "@/lib/time";
import type { ThreadItem } from "@/app/hooks/useThreads";
import {
useThreads,
@@ -52,16 +51,18 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { errorToast } from "@/lib/errorReporter";
import { useTranslations, useFormatter } from "next-intl";
type StatusFilter = "all" | "idle" | "busy" | "interrupted" | "error";
const GROUP_LABELS = {
interrupted: "Requiring Attention",
today: "Today",
yesterday: "Yesterday",
week: "This Week",
older: "Older",
} as const;
const GROUP_KEYS = [
"interrupted",
"today",
"yesterday",
"week",
"older",
] as const;
type GroupKey = (typeof GROUP_KEYS)[number];
const STATUS_COLORS: Record<ThreadItem["status"], string> = {
idle: "bg-transparent ring-1 ring-inset ring-green-500",
@@ -102,9 +103,10 @@ function StatusFilterItem({
}
function ErrorState({ message }: { message: string }) {
const t = useTranslations("threadList");
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<p className="text-sm text-red-600">Failed to load research</p>
<p className="text-sm text-red-600">{t("loadFailed")}</p>
<p className="mt-1 text-xs text-muted-foreground">{message}</p>
</div>
);
@@ -124,10 +126,11 @@ function LoadingState() {
}
function EmptyState() {
const t = useTranslations("threadList");
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<MessageSquare className="mb-2 h-12 w-12 text-gray-300" />
<p className="text-sm text-muted-foreground">No research yet</p>
<p className="text-sm text-muted-foreground">{t("empty")}</p>
</div>
);
}
@@ -147,6 +150,9 @@ export function ThreadList({
onMutateReady,
onInterruptCountChange,
}: ThreadListProps) {
const t = useTranslations("threadList");
const tCommon = useTranslations("common");
const format = useFormatter();
const [currentThreadId, setThreadId] = useQueryState("threadId");
const [view, setView] = useQueryState("view");
// Badge the Memory nav when long-term memory changed since last viewed.
@@ -245,7 +251,7 @@ export function ThreadList({
// Group threads by time and status
const grouped = useMemo(() => {
const groups: Record<keyof typeof GROUP_LABELS, ThreadItem[]> = {
const groups: Record<GroupKey, ThreadItem[]> = {
interrupted: [],
today: [],
yesterday: [],
@@ -279,6 +285,40 @@ export function ThreadList({
return groups;
}, [filtered, now, needsAttention]);
const groupLabels: Record<GroupKey, string> = {
interrupted: t("groups.requiringAttention"),
today: t("groups.today"),
yesterday: t("groups.yesterday"),
week: t("groups.thisWeek"),
older: t("groups.older"),
};
// Locale-aware replacements for lib/time.ts (which hardcodes "Yesterday" and
// uses the browser locale; we want the app locale).
const formatThreadTime = (date: Date): string => {
if (!Number.isFinite(date.getTime())) return "—";
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days <= 0) {
return format.dateTime(date, {
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
});
}
if (days === 1) return t("groups.yesterday");
if (days < 7) return format.dateTime(date, { weekday: "long" });
return format.dateTime(date, { month: "2-digit", day: "2-digit" });
};
const formatThreadFullTime = (date: Date): string => {
if (!Number.isFinite(date.getTime())) return t("unknownTime");
return format.dateTime(date, {
dateStyle: "medium",
timeStyle: "short",
hourCycle: "h23",
});
};
const interruptedCount = useMemo(() => {
// Same predicate as the `grouped` bucket so the badge matches the visible
// group exactly.
@@ -338,7 +378,7 @@ export function ThreadList({
setRenameTarget(null);
mutateFn();
} catch {
errorToast("threads.rename", "Couldn't rename — try again.");
errorToast("threads.rename", t("renameFailed"));
} finally {
actionBusyRef.current = false;
setActionBusy(false);
@@ -366,7 +406,7 @@ export function ThreadList({
setDeleteTarget(null);
mutateFn();
} catch {
errorToast("threads.delete", "Couldn't delete — try again.");
errorToast("threads.delete", t("deleteFailed"));
} finally {
actionBusyRef.current = false;
setActionBusy(false);
@@ -387,9 +427,7 @@ export function ThreadList({
} catch {
errorToast(
"threads.pin",
thread.pinned
? "Couldn't unpin — try again."
: "Couldn't pin — try again."
thread.pinned ? t("unpinFailed") : t("pinFailed")
);
} finally {
pinBusyIdsRef.current.delete(thread.id);
@@ -412,7 +450,7 @@ export function ThreadList({
try {
await exportThread(thread.id, thread.title);
} catch {
errorToast("threads.export", "Couldn't export — try again.");
errorToast("threads.export", t("exportFailed"));
} finally {
exportBusyIdsRef.current.delete(thread.id);
setExportBusyIds((current) => {
@@ -464,9 +502,9 @@ export function ThreadList({
<span className="ml-2 flex-shrink-0 text-xs tabular-nums text-muted-foreground">
<time
dateTime={thread.updatedAt.toISOString()}
title={formatFullTime(thread.updatedAt)}
title={formatThreadFullTime(thread.updatedAt)}
>
{formatTime(thread.updatedAt, now)}
{formatThreadTime(thread.updatedAt)}
</time>
</span>
</div>
@@ -479,10 +517,10 @@ export function ThreadList({
type="button"
aria-label={
thread.pinned
? `Unpin "${thread.title}"`
: `Pin "${thread.title}"`
? t("unpinAria", { title: thread.title })
: t("pinAria", { title: thread.title })
}
title={thread.pinned ? "Unpin" : "Pin"}
title={thread.pinned ? t("unpin") : t("pin")}
onClick={() => togglePin(thread)}
disabled={pinBusy}
className={cn(
@@ -506,8 +544,8 @@ export function ThreadList({
</button>
<button
type="button"
aria-label={`Rename "${thread.title}"`}
title="Rename"
aria-label={t("renameAria", { title: thread.title })}
title={t("rename")}
onClick={() => {
setRenameTarget(thread);
setRenameValue(thread.title);
@@ -521,8 +559,8 @@ export function ThreadList({
</button>
<button
type="button"
aria-label={`Export "${thread.title}" as JSON`}
title="Export JSON"
aria-label={t("exportAria", { title: thread.title })}
title={t("exportJson")}
onClick={() => runExport(thread)}
disabled={exportBusy}
className="rounded p-1 text-muted-foreground transition-colors hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
@@ -541,8 +579,8 @@ export function ThreadList({
</button>
<button
type="button"
aria-label={`Delete "${thread.title}"`}
title="Delete"
aria-label={t("deleteAria", { title: thread.title })}
title={tCommon("delete")}
onClick={() => setDeleteTarget(thread)}
className="rounded p-1 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:ring-2 focus-visible:ring-ring"
>
@@ -576,7 +614,7 @@ export function ThreadList({
className="size-4"
aria-hidden="true"
/>
New Chat
{t("newChat")}
</button>
<button
type="button"
@@ -599,7 +637,7 @@ export function ThreadList({
className="size-4"
aria-hidden="true"
/>
Research Skills
{t("researchSkills")}
</button>
<button
type="button"
@@ -623,14 +661,12 @@ export function ThreadList({
className="size-4"
aria-hidden="true"
/>
EvoMemory
{t("evoMemory")}
{view !== "memory" && memoryUnseen > 0 && (
<span
className="ml-auto inline-flex min-w-4 items-center justify-center rounded-full bg-[var(--brand-solid)] px-1 text-[10px] font-bold leading-none text-[var(--brand-foreground)]"
title={`${memoryUnseen} memory file${
memoryUnseen === 1 ? "" : "s"
} updated since you last looked`}
aria-label={`${memoryUnseen} memory updates`}
title={t("memoryUpdatesTitle", { count: memoryUnseen })}
aria-label={t("memoryUpdatesAria", { count: memoryUnseen })}
>
{memoryUnseen}
</span>
@@ -657,7 +693,7 @@ export function ThreadList({
className="size-4"
aria-hidden="true"
/>
Scheduled
{t("scheduled")}
</button>
<div className="flex-shrink-0 border-b border-border p-2.5">
<div className="relative">
@@ -670,8 +706,8 @@ export function ThreadList({
name="research-search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search research…"
aria-label="Search research"
placeholder={t("searchPlaceholder")}
aria-label={t("searchAria")}
autoComplete="off"
spellCheck={false}
className="w-full rounded-md border border-border bg-background py-1.5 pl-8 pr-8 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
@@ -679,7 +715,7 @@ export function ThreadList({
{search && (
<button
type="button"
aria-label="Clear research search"
aria-label={t("clearSearch")}
onClick={() => setSearch("")}
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
@@ -692,7 +728,9 @@ export function ThreadList({
</div>
</div>
<div className="grid flex-shrink-0 grid-cols-[1fr_auto] items-center gap-2 border-b border-border px-3 py-2.5">
<h2 className="text-base font-semibold tracking-tight">Recents</h2>
<h2 className="text-base font-semibold tracking-tight">
{t("recents")}
</h2>
<div className="flex items-center gap-2">
<Select
value={statusFilter}
@@ -702,37 +740,37 @@ export function ThreadList({
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="all">All</SelectItem>
<SelectItem value="all">{t("filterAll")}</SelectItem>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Active</SelectLabel>
<SelectLabel>{t("filterActive")}</SelectLabel>
<SelectItem value="idle">
<StatusFilterItem
status="idle"
label="Idle"
label={t("filterIdle")}
/>
</SelectItem>
<SelectItem value="busy">
<StatusFilterItem
status="busy"
label="Busy"
label={t("filterBusy")}
/>
</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Attention</SelectLabel>
<SelectLabel>{t("filterAttention")}</SelectLabel>
<SelectItem value="interrupted">
<StatusFilterItem
status="interrupted"
label="Interrupted"
label={t("filterInterrupted")}
badge={interruptedCount}
/>
</SelectItem>
<SelectItem value="error">
<StatusFilterItem
status="error"
label="Error"
label={t("filterError")}
/>
</SelectItem>
</SelectGroup>
@@ -741,7 +779,7 @@ export function ThreadList({
{onClose && (
<button
type="button"
aria-label={view ? "Close navigation" : "Close research"}
aria-label={view ? t("closeNavigation") : t("closeResearch")}
onClick={onClose}
className="rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
@@ -771,7 +809,7 @@ export function ThreadList({
filtered.length === 0 && (
<div className="flex flex-col items-center justify-center p-8 text-center">
<p className="text-sm text-muted-foreground">
No research matches your search.
{t("noMatch")}
</p>
</div>
)}
@@ -782,7 +820,7 @@ export function ThreadList({
{pinned.length > 0 && (
<div className="mb-3">
<h4 className="m-0 px-2.5 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Research
{t("pinnedSection")}
</h4>
<div className="flex flex-col gap-1">
{pinned.map((thread) => renderThreadCard(thread))}
@@ -790,9 +828,7 @@ export function ThreadList({
</div>
)}
{(
Object.keys(GROUP_LABELS) as Array<keyof typeof GROUP_LABELS>
).map((group) => {
{GROUP_KEYS.map((group) => {
const groupThreads = grouped[group];
if (groupThreads.length === 0) return null;
@@ -802,7 +838,7 @@ export function ThreadList({
className="mb-3"
>
<h4 className="m-0 px-2.5 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{GROUP_LABELS[group]}
{groupLabels[group]}
</h4>
<div className="flex flex-col gap-1">
{groupThreads.map((thread) => renderThreadCard(thread))}
@@ -822,10 +858,10 @@ export function ThreadList({
{isLoadingMore ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading…
{tCommon("loading")}
</>
) : (
"Load More"
t("loadMore")
)}
</Button>
</div>
@@ -844,9 +880,9 @@ export function ThreadList({
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Rename research</DialogTitle>
<DialogTitle>{t("renameDialog.title")}</DialogTitle>
<DialogDescription>
Give this conversation a custom title.
{t("renameDialog.description")}
</DialogDescription>
</DialogHeader>
<Input
@@ -859,7 +895,7 @@ export function ThreadList({
submitRename();
}
}}
placeholder="Enter a title…"
placeholder={t("renameDialog.placeholder")}
maxLength={100}
disabled={actionBusy}
/>
@@ -869,13 +905,13 @@ export function ThreadList({
onClick={() => setRenameTarget(null)}
disabled={actionBusy}
>
Cancel
{tCommon("cancel")}
</Button>
<Button
onClick={submitRename}
disabled={actionBusy || !renameValue.trim()}
>
{actionBusy ? "Saving…" : "Save"}
{actionBusy ? t("renameDialog.saving") : tCommon("save")}
</Button>
</DialogFooter>
</DialogContent>
@@ -902,10 +938,11 @@ export function ThreadList({
}}
>
<DialogHeader>
<DialogTitle>Delete this research?</DialogTitle>
<DialogTitle>{t("deleteDialog.title")}</DialogTitle>
<DialogDescription>
“{deleteTarget?.title}” will be permanently deleted. This can’t be
undone.
{t("deleteDialog.description", {
title: deleteTarget?.title ?? "",
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -914,14 +951,14 @@ export function ThreadList({
onClick={() => setDeleteTarget(null)}
disabled={actionBusy}
>
Cancel
{tCommon("cancel")}
</Button>
<Button
onClick={confirmDelete}
disabled={actionBusy}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{actionBusy ? "Deleting…" : "Delete"}
{actionBusy ? t("deleteDialog.deleting") : tCommon("delete")}
</Button>
</DialogFooter>
</DialogContent>
+23 -14
View File
@@ -8,6 +8,7 @@ import { AlertCircle, Check, X, Pencil, ShieldCheck } from "lucide-react";
import type { ActionRequest, ReviewConfig } from "@/app/types/types";
import { cn } from "@/lib/utils";
import type { ReviewMode } from "@/lib/reviewMode";
import { useTranslations } from "next-intl";
interface ToolApprovalInterruptProps {
actionRequest: ActionRequest;
@@ -48,6 +49,8 @@ export function ToolApprovalInterrupt({
isLoading,
onSubmitted,
}: ToolApprovalInterruptProps) {
const t = useTranslations("chat");
const tCommon = useTranslations("common");
const [rejectionMessage, setRejectionMessage] = useState("");
const [isEditing, setIsEditing] = useState(false);
const [editedArgs, setEditedArgs] = useState<Record<string, unknown>>({});
@@ -167,7 +170,7 @@ export function ToolApprovalInterrupt({
aria-hidden="true"
/>
<span className="text-xs font-semibold uppercase tracking-wider">
Approval Required
{t("toolApproval.title")}
</span>
</div>
@@ -182,7 +185,7 @@ export function ToolApprovalInterrupt({
<div className="mb-4 rounded-sm border border-border bg-background p-3">
<div className="mb-2">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Tool
{t("toolApproval.tool")}
</span>
<p className="mt-1 font-mono text-sm font-medium text-foreground">
{actionRequest.name}
@@ -192,7 +195,7 @@ export function ToolApprovalInterrupt({
{isEditing ? (
<div>
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Edit Arguments
{t("toolApproval.editArguments")}
</span>
<div className="mt-2 space-y-3">
{Object.entries(actionArgs).map(([key, value]) => (
@@ -224,7 +227,7 @@ export function ToolApprovalInterrupt({
) : (
<div>
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Arguments
{t("toolApproval.arguments")}
</span>
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-all rounded-sm border border-border bg-muted/40 p-2 font-mono text-xs text-foreground">
{formatValue(actionArgs)}
@@ -237,12 +240,12 @@ export function ToolApprovalInterrupt({
{showRejectionInput && !isEditing && (
<div className="mb-4">
<label className="mb-2 block text-xs font-medium text-foreground">
Rejection Message (optional)
{t("toolApproval.rejectionMessage")}
</label>
<Textarea
value={rejectionMessage}
onChange={(e) => setRejectionMessage(e.target.value)}
placeholder="Explain why you're rejecting this action…"
placeholder={t("toolApproval.rejectionPlaceholder")}
className="text-sm"
rows={2}
disabled={isLoading}
@@ -261,7 +264,7 @@ export function ToolApprovalInterrupt({
onClick={cancelEditing}
disabled={isLoading}
>
Cancel
{tCommon("cancel")}
</Button>
<Button
type="button"
@@ -274,7 +277,9 @@ export function ToolApprovalInterrupt({
size={14}
aria-hidden="true"
/>
{isLoading ? "Saving…" : "Save & Approve"}
{isLoading
? t("toolApproval.saving")
: t("toolApproval.saveApprove")}
</Button>
</>
) : showRejectionInput ? (
@@ -289,7 +294,7 @@ export function ToolApprovalInterrupt({
}}
disabled={isLoading}
>
Cancel
{tCommon("cancel")}
</Button>
<Button
type="button"
@@ -298,7 +303,9 @@ export function ToolApprovalInterrupt({
onClick={handleRejectConfirm}
disabled={isLoading}
>
{isLoading ? "Rejecting…" : "Confirm Reject"}
{isLoading
? t("toolApproval.rejecting")
: t("toolApproval.confirmReject")}
</Button>
</>
) : (
@@ -316,7 +323,7 @@ export function ToolApprovalInterrupt({
size={14}
aria-hidden="true"
/>
Reject
{t("toolApproval.reject")}
</Button>
)}
{allowedDecisions.includes("edit") && (
@@ -331,7 +338,7 @@ export function ToolApprovalInterrupt({
size={14}
aria-hidden="true"
/>
Edit
{t("toolApproval.edit")}
</Button>
)}
{allowedDecisions.includes("approve") && (
@@ -350,7 +357,9 @@ export function ToolApprovalInterrupt({
size={14}
aria-hidden="true"
/>
{isLoading ? "Approving…" : "Approve once"}
{isLoading
? t("toolApproval.approving")
: t("toolApproval.approveOnce")}
</Button>
<Button
type="button"
@@ -364,7 +373,7 @@ export function ToolApprovalInterrupt({
size={14}
aria-hidden="true"
/>
Auto-approve this chat
{t("toolApproval.approveAuto")}
</Button>
</>
)}
+10 -6
View File
@@ -21,7 +21,9 @@ import { ToolCall, ActionRequest, ReviewConfig } from "@/app/types/types";
import { cn } from "@/lib/utils";
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
import { ToolApprovalInterrupt } from "@/app/components/ToolApprovalInterrupt";
import { formatToolLabel, toolArgumentPreview } from "@/lib/toolLabel";
import { formatToolLabelWith, toolArgumentPreview } from "@/lib/toolLabel";
import { useToolLabels } from "@/app/hooks/useToolLabels";
import { useTranslations } from "next-intl";
import type { ReviewMode } from "@/lib/reviewMode";
// One-line preview of a tool-call argument value, shown next to the key in
@@ -75,6 +77,8 @@ export const ToolCallBox = React.memo<ToolCallBoxProps>(
autoApprove,
compact = false,
}) => {
const t = useTranslations("chat");
const toolLabels = useToolLabels();
const [isExpanded, setIsExpanded] = useState(
() => !!uiComponent || (!!actionRequest && !autoApprove)
);
@@ -126,7 +130,7 @@ export const ToolCallBox = React.memo<ToolCallBoxProps>(
// args view, so Object.keys/entries never run on a string.
const rawArgs: unknown = toolCall.args;
return {
name: formatToolLabel(toolCall.name, rawArgs),
name: formatToolLabelWith(toolLabels, toolCall.name, rawArgs),
argumentPreview: toolArgumentPreview(toolCall.name, rawArgs),
args:
rawArgs && typeof rawArgs === "object"
@@ -135,7 +139,7 @@ export const ToolCallBox = React.memo<ToolCallBoxProps>(
result: toolCall.result,
status: toolCall.status || "completed",
};
}, [toolCall]);
}, [toolCall, toolLabels]);
const statusIcon = useMemo(() => {
switch (status) {
@@ -256,7 +260,7 @@ export const ToolCallBox = React.memo<ToolCallBoxProps>(
stream={stream}
message={uiComponent}
namespace={graphId}
meta={{ status, args, result: result ?? "No Result Yet" }}
meta={{ status, args, result: result ?? t("toolCall.noResultYet") }}
/>
</div>
) : showApproval ? (
@@ -281,7 +285,7 @@ export const ToolCallBox = React.memo<ToolCallBoxProps>(
{Object.keys(args).length > 0 && (
<div className={cn("mt-4", compact && "mt-2")}>
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Arguments
{t("toolCall.arguments")}
</h4>
<div className="space-y-2">
{Object.entries(args).map(([key, value]) => {
@@ -354,7 +358,7 @@ export const ToolCallBox = React.memo<ToolCallBoxProps>(
{hasResult && (
<div className={cn("mt-4", compact && "mt-2")}>
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Result
{t("toolCall.result")}
</h4>
<pre
className={cn(
+23 -20
View File
@@ -12,6 +12,7 @@ import {
} from "@/lib/contextUsage";
import { findActiveTurnId } from "@/lib/usageTurn";
import { errorToast } from "@/lib/errorReporter";
import { useTranslations } from "next-intl";
import type { ModelRef, ThreadModelSelection } from "@/lib/modelRegistry";
import { setThreadModelSelection, setThreadRunning } from "@/app/hooks/useThreads";
import {
@@ -228,7 +229,7 @@ function latestTaskInterrupt(
// `message`, JSON-of-`.error`, the raw string, finally a generic fallback.
// Capped at 300 chars so a giant stack trace doesn't blow up the toast; the
// full text is still available in the thread JSON via the export affordance.
function formatStreamError(error: unknown): string {
function formatStreamError(error: unknown): string | null {
const cap = (s: string) => (s.length > 300 ? s.slice(0, 297) + "..." : s);
if (typeof error === "string" && error.trim()) return cap(error.trim());
if (error && typeof error === "object") {
@@ -249,7 +250,8 @@ function formatStreamError(error: unknown): string {
const combined = name && body ? `${name}: ${body}` : name ?? body ?? "";
if (combined) return cap(combined);
}
return "Run failed.";
// Caller supplies the localized generic fallback.
return null;
}
// Stable empty fallbacks. Returning `?? []` / `?? {}` inline produces a NEW
@@ -281,6 +283,7 @@ export function useChat({
onHistoryRevalidate?: () => void;
}) {
const [threadId, setThreadId] = useQueryState("threadId");
const t = useTranslations("chat");
void activeAssistant;
const [runConnection, setRunConnection] = useState<RunConnection | null>(
null
@@ -297,17 +300,20 @@ export function useChat({
const stoppedRunIdsRef = useRef(new Set<string>());
const submittingRef = useRef(false);
const showError = useCallback((error: unknown, key: string) => {
const message = formatStreamError(error);
const now = Date.now();
if (
lastErrorToastRef.current?.key !== `${key}:${message}` ||
now - lastErrorToastRef.current.at > 10_000
) {
lastErrorToastRef.current = { key: `${key}:${message}`, at: now };
errorToast("chat", message);
}
}, []);
const showError = useCallback(
(error: unknown, key: string) => {
const message = formatStreamError(error) ?? t("runFailed");
const now = Date.now();
if (
lastErrorToastRef.current?.key !== `${key}:${message}` ||
now - lastErrorToastRef.current.at > 10_000
) {
lastErrorToastRef.current = { key: `${key}:${message}`, at: now };
errorToast("chat", message);
}
},
[t]
);
const isRunLoading = isSubmitting || runConnection !== null;
const isReconnecting = runConnection?.phase === "reconnecting";
@@ -422,10 +428,10 @@ export function useChat({
let cancelled = false;
void (async () => {
try {
const { thread: t } = await getConversation(threadId);
const { thread } = await getConversation(threadId);
if (cancelled) return;
const stored = readSelectionFromMetadata(
(t.metadata ?? {}) as Record<string, unknown>
(thread.metadata ?? {}) as Record<string, unknown>
);
setModelSelectionState(stored.selection);
setSelectionRevision(stored.revision);
@@ -437,17 +443,14 @@ export function useChat({
activeRunRef.current = null;
setRunConnection(null);
void setThreadId(null);
errorToast(
"chat.conversation",
"Conversation is no longer available. Started a new chat."
);
errorToast("chat.conversation", t("conversationUnavailable"));
}
}
})();
return () => {
cancelled = true;
};
}, [setThreadId, threadId]);
}, [setThreadId, t, threadId]);
// Persist + apply locally. Pre-thread (new chat with no threadId yet),
// stashes the pick in a ref so the thread-id effect can persist it as soon
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useTranslations } from "next-intl";
import type { ToolLabelStrings } from "@/lib/toolLabel";
/**
* Locale-aware tool labels for `formatToolLabelWith`. Tool labels are rendered
* inside ToolCallBox / ActionGroup headers, so they follow the chat domain.
*/
export function useToolLabels(): ToolLabelStrings {
const t = useTranslations("chat.toolLabels");
return {
unknownTool: t("unknownTool"),
adaptiveToolSelection: t("adaptiveToolSelection"),
reflection: t("reflection"),
readingMemory: t("readingMemory"),
updatingMemory: t("updatingMemory"),
skill: (name) => t("skill", { name }),
execute: t("execute"),
grep: t("grep"),
readFile: t("readFile"),
writeFile: t("writeFile"),
editFile: t("editFile"),
listFiles: t("listFiles"),
task: t("task"),
};
}
+251 -1
View File
@@ -1,3 +1,253 @@
export default {
chat: {},
chat: {
send: "Send",
sendMessage: "Send message",
stop: "Stop",
stopGenerating: "Stop generating",
messageInputLabel: "Message",
placeholderDefault: "Ask EvoScientist anything…",
placeholderQueueFollowUp: "Queue a follow-up — sends when this turn finishes…",
placeholderReconnecting: "Reconnecting to the running task…",
placeholderRespondToInterrupt: "Respond to the request above to continue…",
emptyStateTitle: "Where research evolves",
emptyStateSubtitle:
"Your self-evolving lab partner — reads the literature, runs experiments, and remembers what matters.",
suggestedSurvey: "Survey recent papers on a topic",
suggestedExperiment: "Design an experiment plan",
suggestedFiles: "Analyze workspace files",
editMessage: "Edit message",
thinkingTimer: "Thinking…",
runFailed: "Run failed.",
conversationUnavailable:
"Conversation is no longer available. Started a new chat.",
upload: {
aria: "Upload files to workspace",
title: "Upload files to workspace (max 50 MB each)",
failed: "Failed to upload files.",
uploaded:
"{count, plural, one {# file uploaded to the workspace.} other {# files uploaded to the workspace.}}",
attachedFiles: "Attached files",
removeAttachment: "Remove {name} from this message",
},
reviewMode: {
label: "Review mode",
manual: {
label: "Manual review",
description: "Approve each tool action before it runs.",
},
auto: {
label: "Auto-approve",
description:
"Approve tool actions automatically; questions can still pause.",
},
full: {
label: "Full approve",
description:
"Approve tool actions automatically and continue without questions.",
},
autoEnabled: "Auto-approve enabled for this chat.",
manualEnabled: "Manual review enabled for this chat.",
fullEnabled: "Full approve enabled for this chat.",
fullApprove: {
title: "Enable Full approve?",
description:
"EvoScientist will approve reviewed tool actions automatically and will not pause to ask you for clarification.",
warning:
"When details are missing, the model will make reasonable, conservative assumptions and continue. This setting applies only to the current research.",
confirm: "Enable Full approve",
},
},
modelPicker: {
title: "Choose model",
description:
"Saved for this chat; the next message freezes it into the run’s snapshot.",
current: "Current:",
currentDefault: "{model} · default",
registryDefault: "registry default",
loadingModels: "loading models…",
unavailable: "The deployment’s selectable model list didn’t load",
filterPlaceholder: "Filter by name or provider…",
noMatch: "No models match “{query}”.",
noneEnabled: "No models are enabled.",
setSuccess: "Model set to {model}.",
resetSuccess: "Model reset to the registry default.",
updateFailed: "Couldn’t update model: {message}",
updateFailedGeneric: "Couldn’t update model — try again.",
resetFailed: "Couldn’t reset model: {message}",
resetFailedGeneric: "Couldn’t reset model — try again.",
resetToDefault: "Reset to default",
changeTitle: "Click to change model for this chat",
changeAria: "Change model for this chat",
},
reasoning: {
aria: "Reasoning effort",
title: "Reasoning effort for the primary model",
titleDisabled: "Pick a model to override the registry-configured effort",
auto: "auto",
low: "low",
medium: "medium",
high: "high",
reset: "Reasoning effort back to the registry default.",
set: "Reasoning effort set to {effort}.",
failed: "Couldn’t update reasoning effort: {message}",
failedGeneric: "Couldn’t update reasoning effort — try again.",
},
sampling: {
aria: "Sampling override",
title: "Sampling override: temperature and top_p are mutually exclusive",
titleDisabled:
"Pick a model to override the registry-configured sampling",
modeDefault: "Default",
temperature: "Temperature",
topP: "Top P",
unset: "unset",
exactValue: "{label} exact value",
clickToType: "Click to type an exact {label} value",
sliderTitleDisabled:
"Pick a model to override the registry-configured {label}",
sliderTitleDefault:
"{label}: registry default {value} — drag to override or click the value to type one",
sliderTitleOverride:
"{label} override — click the value to type an exact one; empty + Enter resets",
reset: "Sampling back to the registry default.",
temperatureSet: "Temperature set to {value} (top_p omitted).",
topPSet: "Top P set to {value} (temperature omitted).",
failed: "Couldn’t update sampling: {message}",
failedGeneric: "Couldn’t update sampling — try again.",
},
usage: {
tokensLoading: "Tokens loading",
tokensUnavailable: "Tokens unavailable",
tokensLabel: "{count} tokens",
viewAria: "View token usage: {label}",
},
agents: {
runningTitle:
"{count, plural, one {# background agent running — click to view} other {# background agents running — click to view}}",
runningAria:
"{count, plural, one {# background agent running — view} other {# background agents running — view}}",
runningLabel: "{count, plural, one {# agent} other {# agents}}",
autoReported: "Auto-reported {agent} to the main chat.",
},
queue: {
label: "Queued messages",
filesAttached: "{count, plural, one {# file} other {# files}}",
reorderAria: "Reorder queued message {index} of {total}",
reorderTitle: "Drag to reorder, or use the arrow keys",
steerAria: "Steer with this message next",
steerTitle: "Submit next without interrupting the current turn",
steer: "Steer",
steerToast: "This message will run next without interrupting the current turn.",
removeAria: "Remove queued message",
removeTitle: "Remove from queue",
moreAria: "More queued message actions",
moreTitle: "More actions",
closeQueue: "Close queue",
},
tasks: {
allCompleted: "All tasks completed",
progress: "Task {current} of {total}",
filesState: "Files (State)",
tasks: "Tasks",
pending: "Pending",
inProgress: "In Progress",
completed: "Completed",
},
workspace: {
files: "Files",
openFiles: "Open conversation files",
closeFiles: "Close conversation files",
},
message: {
copy: "Copy message",
copied: "Copied",
copyFailed: "Couldn’t copy to clipboard.",
thinking: "Thinking",
backgroundAgentReported: "Background agent reported back",
},
subAgent: {
badge: "subagent",
input: "Input",
steps: "Steps",
output: "Output",
},
toolCall: {
arguments: "Arguments",
result: "Result",
noResultYet: "No Result Yet",
},
toolApproval: {
title: "Approval Required",
tool: "Tool",
editArguments: "Edit Arguments",
arguments: "Arguments",
rejectionMessage: "Rejection Message (optional)",
rejectionPlaceholder: "Explain why you’re rejecting this action…",
saveApprove: "Save & Approve",
saving: "Saving…",
confirmReject: "Confirm Reject",
rejecting: "Rejecting…",
reject: "Reject",
edit: "Edit",
approveOnce: "Approve once",
approving: "Approving…",
approveAuto: "Auto-approve this chat",
},
askUser: {
title: "EvoScientist Needs Your Input",
other: "Other…",
answerPlaceholder: "Type your answer…",
answerPlaceholderOptional: "Type your answer… (optional)",
submit: "Submit",
submitting: "Submitting…",
},
compaction: {
title: "Conversation compacted",
summarized:
"{count, plural, one {# earlier message summarized} other {# earlier messages summarized}}",
tokenStats: "≈{before} → ≈{after} tokens",
detailsWithoutBudget:
"Prompt size (estimate): ≈{before} tokens before compaction, ≈{after} after. Exact usage appears in token consumption once the next model call completes.",
detailsWithBudget:
"Prompt size (estimate): ≈{before} tokens before compaction, ≈{after} after · hard message budget ≈{budget} tokens. Exact usage appears in token consumption once the next model call completes.",
},
actionGroup: {
ariaLabel: "Assistant work process",
working: "Working",
approvalRequired: "Approval required",
failed:
"{failed} of {total, plural, one {# action} other {# actions}} failed",
workingWithCount:
"Working · {total, plural, one {# action} other {# actions}}",
completed:
"{total, plural, one {# action} other {# actions}} completed",
collapse: "Collapse",
collapseCount:
"Collapse {total, plural, one {# action} other {# actions}}",
collapseGroup: "Collapse {summary}",
expandGroup: "Expand {summary}",
},
contextUsage: {
label: "ctx {percent}% · {used}/{limit}",
tooltipMeasured:
"Last model call used {used} of {limit} tokens (measured) · auto-compact messages at ≈{soft}.",
tooltipEstimate:
"Context used (estimate): {used} of {limit} tokens · auto-compact messages at ≈{soft}.",
},
toolLabels: {
unknownTool: "Unknown Tool",
adaptiveToolSelection: "Adaptive tool selection",
reflection: "Reflection",
readingMemory: "Reading memory",
updatingMemory: "Updating memory",
skill: "Skill: {name}",
execute: "Running command",
grep: "Searching files",
readFile: "Reading file",
writeFile: "Writing file",
editFile: "Editing file",
listFiles: "Listing files",
task: "Running subagent",
},
},
};
+62 -1
View File
@@ -1,3 +1,64 @@
export default {
threadList: {},
threadList: {
newChat: "New Chat",
researchSkills: "Research Skills",
evoMemory: "EvoMemory",
memoryUpdatesTitle:
"{count, plural, one {# memory file updated since you last looked} other {# memory files updated since you last looked}}",
memoryUpdatesAria:
"{count, plural, one {# memory update} other {# memory updates}}",
scheduled: "Scheduled",
searchPlaceholder: "Search research…",
searchAria: "Search research",
clearSearch: "Clear research search",
recents: "Recents",
filterAll: "All",
filterActive: "Active",
filterIdle: "Idle",
filterBusy: "Busy",
filterAttention: "Attention",
filterInterrupted: "Interrupted",
filterError: "Error",
closeNavigation: "Close navigation",
closeResearch: "Close research",
loadFailed: "Failed to load research",
empty: "No research yet",
noMatch: "No research matches your search.",
pinnedSection: "Research",
groups: {
requiringAttention: "Requiring Attention",
today: "Today",
yesterday: "Yesterday",
thisWeek: "This Week",
older: "Older",
},
loadMore: "Load More",
unknownTime: "Unknown time",
pin: "Pin",
unpin: "Unpin",
pinAria: "Pin “{title}”",
unpinAria: "Unpin “{title}”",
rename: "Rename",
renameAria: "Rename “{title}”",
exportJson: "Export JSON",
exportAria: "Export “{title}” as JSON",
deleteAria: "Delete “{title}”",
renameDialog: {
title: "Rename research",
description: "Give this conversation a custom title.",
placeholder: "Enter a title…",
saving: "Saving…",
},
deleteDialog: {
title: "Delete this research?",
description:
"“{title}” will be permanently deleted. This can’t be undone.",
deleting: "Deleting…",
},
renameFailed: "Couldn’t rename — try again.",
deleteFailed: "Couldn’t delete — try again.",
pinFailed: "Couldn’t pin — try again.",
unpinFailed: "Couldn’t unpin — try again.",
exportFailed: "Couldn’t export — try again.",
},
};
+238 -1
View File
@@ -1,3 +1,240 @@
export default {
chat: {},
chat: {
send: "发送",
sendMessage: "发送消息",
stop: "停止",
stopGenerating: "停止生成",
messageInputLabel: "消息",
placeholderDefault: "向 EvoScientist 提问…",
placeholderQueueFollowUp: "排队一条追问 —— 本轮结束后自动发送…",
placeholderReconnecting: "正在重新连接运行中的任务…",
placeholderRespondToInterrupt: "请先回应上方的请求以继续…",
emptyStateTitle: "让研究持续进化",
emptyStateSubtitle:
"你的自我进化实验室伙伴 —— 阅读文献、运行实验,并记住关键内容。",
suggestedSurvey: "调研某主题的最新论文",
suggestedExperiment: "设计实验方案",
suggestedFiles: "分析工作区文件",
editMessage: "编辑消息",
thinkingTimer: "思考中…",
runFailed: "运行失败。",
conversationUnavailable: "对话已不可用,已开启新对话。",
upload: {
aria: "上传文件到工作区",
title: "上传文件到工作区(单个文件最大 50 MB)",
failed: "文件上传失败。",
uploaded: "{count, plural, other {已将 # 个文件上传到工作区。}}",
attachedFiles: "已附加的文件",
removeAttachment: "将 {name} 从这条消息中移除",
},
reviewMode: {
label: "审核模式",
manual: {
label: "手动审核",
description: "每个工具操作运行前都需要你批准。",
},
auto: {
label: "自动批准",
description: "自动批准工具操作;提问仍会暂停。",
},
full: {
label: "完全批准",
description: "自动批准工具操作,且不再因提问而暂停。",
},
autoEnabled: "已为此对话启用自动批准。",
manualEnabled: "已为此对话启用手动审核。",
fullEnabled: "已为此对话启用完全批准。",
fullApprove: {
title: "启用完全批准?",
description:
"EvoScientist 将自动批准需要审核的工具操作,并且不会暂停来向你求证。",
warning:
"当细节缺失时,模型将做出合理、保守的假设并继续执行。此设置仅应用于当前研究。",
confirm: "启用完全批准",
},
},
modelPicker: {
title: "选择模型",
description: "为此对话保存;下一条消息会将其冻结到运行的快照中。",
current: "当前:",
currentDefault: "{model} · 默认",
registryDefault: "注册表默认",
loadingModels: "正在加载模型…",
unavailable: "此部署的可用模型列表未能加载",
filterPlaceholder: "按名称或服务商筛选…",
noMatch: "没有匹配“{query}”的模型。",
noneEnabled: "没有已启用的模型。",
setSuccess: "模型已设置为 {model}。",
resetSuccess: "模型已重置为注册表默认。",
updateFailed: "无法更新模型:{message}",
updateFailedGeneric: "无法更新模型 —— 请重试。",
resetFailed: "无法重置模型:{message}",
resetFailedGeneric: "无法重置模型 —— 请重试。",
resetToDefault: "重置为默认",
changeTitle: "点击更换此对话的模型",
changeAria: "更换此对话的模型",
},
reasoning: {
aria: "推理强度",
title: "主模型的推理强度",
titleDisabled: "先选择模型,才能覆盖注册表配置的推理强度",
auto: "自动",
low: "低",
medium: "中",
high: "高",
reset: "推理强度已恢复为注册表默认。",
set: "推理强度已设置为 {effort}。",
failed: "无法更新推理强度:{message}",
failedGeneric: "无法更新推理强度 —— 请重试。",
},
sampling: {
aria: "采样覆盖",
title: "采样覆盖:temperature 和 top_p 互斥",
titleDisabled: "先选择模型,才能覆盖注册表配置的采样",
modeDefault: "默认",
temperature: "温度",
topP: "Top P",
unset: "未设置",
exactValue: "{label} 精确值",
clickToType: "点击数值以输入精确的 {label}",
sliderTitleDisabled: "先选择模型,才能覆盖注册表配置的 {label}",
sliderTitleDefault:
"{label}:注册表默认 {value} —— 拖动以覆盖,或点击数值直接输入",
sliderTitleOverride:
"{label} 覆盖 —— 点击数值可输入精确值;清空后回车重置",
reset: "采样已恢复为注册表默认。",
temperatureSet: "温度已设置为 {value}(top_p 已省略)。",
topPSet: "Top P 已设置为 {value}(temperature 已省略)。",
failed: "无法更新采样:{message}",
failedGeneric: "无法更新采样 —— 请重试。",
},
usage: {
tokensLoading: "Token 加载中",
tokensUnavailable: "Token 不可用",
tokensLabel: "{count} tokens",
viewAria: "查看 token 用量:{label}",
},
agents: {
runningTitle:
"{count, plural, other {# 个后台代理正在运行 —— 点击查看}}",
runningAria: "{count, plural, other {# 个后台代理正在运行 —— 查看}}",
runningLabel: "{count, plural, other {# 个代理}}",
autoReported: "已将 {agent} 自动回报给主对话。",
},
queue: {
label: "排队消息",
filesAttached: "{count, plural, other {# 个文件}}",
reorderAria: "调整第 {index} 条(共 {total} 条)排队消息的顺序",
reorderTitle: "拖拽排序,或使用方向键",
steerAria: "用这条消息优先引导",
steerTitle: "下一条优先发送,不打断当前回合",
steer: "引导",
steerToast: "这条消息将在当前回合结束后优先执行。",
removeAria: "移除排队消息",
removeTitle: "从队列中移除",
moreAria: "更多排队消息操作",
moreTitle: "更多操作",
closeQueue: "关闭队列",
},
tasks: {
allCompleted: "所有任务已完成",
progress: "任务 {current} / {total}",
filesState: "文件(状态)",
tasks: "任务",
pending: "待处理",
inProgress: "进行中",
completed: "已完成",
},
workspace: {
files: "文件",
openFiles: "打开对话文件",
closeFiles: "关闭对话文件",
},
message: {
copy: "复制消息",
copied: "已复制",
copyFailed: "无法复制到剪贴板。",
thinking: "思考",
backgroundAgentReported: "后台代理已回报",
},
subAgent: {
badge: "子代理",
input: "输入",
steps: "步骤",
output: "输出",
},
toolCall: {
arguments: "参数",
result: "结果",
noResultYet: "暂无结果",
},
toolApproval: {
title: "需要批准",
tool: "工具",
editArguments: "编辑参数",
arguments: "参数",
rejectionMessage: "拒绝理由(可选)",
rejectionPlaceholder: "说明拒绝此操作的原因…",
saveApprove: "保存并批准",
saving: "保存中…",
confirmReject: "确认拒绝",
rejecting: "拒绝中…",
reject: "拒绝",
edit: "编辑",
approveOnce: "仅批准此次",
approving: "批准中…",
approveAuto: "自动批准此对话",
},
askUser: {
title: "EvoScientist 需要你的输入",
other: "其他…",
answerPlaceholder: "输入你的回答…",
answerPlaceholderOptional: "输入你的回答…(可选)",
submit: "提交",
submitting: "提交中…",
},
compaction: {
title: "对话已压缩",
summarized: "{count, plural, other {已总结 # 条较早消息}}",
tokenStats: "≈{before} → ≈{after} tokens",
detailsWithoutBudget:
"提示词大小(估算):压缩前 ≈{before} tokens,压缩后 ≈{after} tokens。下一次模型调用完成后,确切用量会显示在 token 消耗中。",
detailsWithBudget:
"提示词大小(估算):压缩前 ≈{before} tokens,压缩后 ≈{after} tokens · 硬消息预算 ≈{budget} tokens。下一次模型调用完成后,确切用量会显示在 token 消耗中。",
},
actionGroup: {
ariaLabel: "助手工作过程",
working: "执行中",
approvalRequired: "需要批准",
failed: "{total} 项操作中有 {failed} 项失败",
workingWithCount: "执行中 · {total} 项操作",
completed: "{total} 项操作已完成",
collapse: "收起",
collapseCount: "收起 {total} 项操作",
collapseGroup: "收起 {summary}",
expandGroup: "展开 {summary}",
},
contextUsage: {
label: "ctx {percent}% · {used}/{limit}",
tooltipMeasured:
"上次模型调用使用了 {limit} 中的 {used} tokens(实测)· 约 {soft} 时自动压缩消息。",
tooltipEstimate:
"上下文占用(估算):{limit} 中的 {used} tokens · 约 {soft} 时自动压缩消息。",
},
toolLabels: {
unknownTool: "未知工具",
adaptiveToolSelection: "自适应工具选择",
reflection: "反思",
readingMemory: "读取记忆",
updatingMemory: "更新记忆",
skill: "技能:{name}",
execute: "运行命令",
grep: "搜索文件",
readFile: "读取文件",
writeFile: "写入文件",
editFile: "编辑文件",
listFiles: "列出文件",
task: "运行子代理",
},
},
};
+60 -1
View File
@@ -1,3 +1,62 @@
export default {
threadList: {},
threadList: {
newChat: "新对话",
researchSkills: "研究技能",
evoMemory: "EvoMemory",
memoryUpdatesTitle:
"{count, plural, other {自上次查看以来有 # 个记忆文件更新}}",
memoryUpdatesAria: "{count, plural, other {# 条记忆更新}}",
scheduled: "定时任务",
searchPlaceholder: "搜索研究…",
searchAria: "搜索研究",
clearSearch: "清除研究搜索",
recents: "最近",
filterAll: "全部",
filterActive: "活跃",
filterIdle: "空闲",
filterBusy: "忙碌",
filterAttention: "需要注意",
filterInterrupted: "已中断",
filterError: "错误",
closeNavigation: "关闭导航",
closeResearch: "关闭研究",
loadFailed: "加载研究失败",
empty: "暂无研究",
noMatch: "没有匹配搜索的研究。",
pinnedSection: "研究",
groups: {
requiringAttention: "需要注意",
today: "今天",
yesterday: "昨天",
thisWeek: "本周",
older: "更早",
},
loadMore: "加载更多",
unknownTime: "未知时间",
pin: "置顶",
unpin: "取消置顶",
pinAria: "置顶“{title}”",
unpinAria: "取消置顶“{title}”",
rename: "重命名",
renameAria: "重命名“{title}”",
exportJson: "导出 JSON",
exportAria: "将“{title}”导出为 JSON",
deleteAria: "删除“{title}”",
renameDialog: {
title: "重命名研究",
description: "为这个对话设置自定义标题。",
placeholder: "输入标题…",
saving: "保存中…",
},
deleteDialog: {
title: "删除这项研究?",
description: "“{title}” 将被永久删除,此操作无法撤销。",
deleting: "删除中…",
},
renameFailed: "无法重命名 —— 请重试。",
deleteFailed: "无法删除 —— 请重试。",
pinFailed: "无法置顶 —— 请重试。",
unpinFailed: "无法取消置顶 —— 请重试。",
exportFailed: "无法导出 —— 请重试。",
},
};
+68 -17
View File
@@ -41,15 +41,54 @@ function asArgsObject(args: unknown): Record<string, unknown> {
return {};
}
export function formatToolLabel(name: string, args?: unknown): string {
if (!name) return "Unknown Tool";
/** Translated strings for {@link formatToolLabelWith}; matches the
* `chat.toolLabels.*` catalog keys. */
export interface ToolLabelStrings {
unknownTool: string;
adaptiveToolSelection: string;
reflection: string;
readingMemory: string;
updatingMemory: string;
/** Skill label; `{name}` is the skill directory name. */
skill: (name: string) => string;
execute: string;
grep: string;
readFile: string;
writeFile: string;
editFile: string;
listFiles: string;
task: string;
}
const EN_TOOL_LABELS: ToolLabelStrings = {
unknownTool: "Unknown Tool",
adaptiveToolSelection: "Adaptive tool selection",
reflection: "Reflection",
readingMemory: "Reading memory",
updatingMemory: "Updating memory",
skill: (name) => `Skill: ${name}`,
execute: "Running command",
grep: "Searching files",
readFile: "Reading file",
writeFile: "Writing file",
editFile: "Editing file",
listFiles: "Listing files",
task: "Running subagent",
};
export function formatToolLabelWith(
labels: ToolLabelStrings,
name: string,
args?: unknown
): string {
if (!name) return labels.unknownTool;
// The tool-selector middleware's internal structured-output call.
if (name === "ToolSelectionResponse") return "Adaptive tool selection";
if (name === "ToolSelectionResponse") return labels.adaptiveToolSelection;
const nameLower = name.toLowerCase();
if (nameLower === "think_tool") return "Reflection";
if (nameLower === "think_tool") return labels.reflection;
if (
nameLower === "read_file" ||
@@ -59,30 +98,42 @@ export function formatToolLabel(name: string, args?: unknown): string {
const a = asArgsObject(args);
const path = String(a.path ?? a.file_path ?? "");
if (isMemoryPath(path)) {
return nameLower === "read_file" ? "Reading memory" : "Updating memory";
return nameLower === "read_file"
? labels.readingMemory
: labels.updatingMemory;
}
if (nameLower === "read_file") {
const skill = skillNameFromPath(path);
if (skill) return `Skill: ${skill}`;
if (skill) return labels.skill(skill);
}
}
const commonLabels: Record<string, string> = {
execute: "Running command",
grep: "Searching files",
read_file: "Reading file",
write_file: "Writing file",
edit_file: "Editing file",
ls: "Listing files",
list_files: "Listing files",
task: "Running subagent",
};
if (commonLabels[nameLower]) return commonLabels[nameLower];
switch (nameLower) {
case "execute":
return labels.execute;
case "grep":
return labels.grep;
case "read_file":
return labels.readFile;
case "write_file":
return labels.writeFile;
case "edit_file":
return labels.editFile;
case "ls":
case "list_files":
return labels.listFiles;
case "task":
return labels.task;
}
// Everything else keeps its raw tool name.
return name;
}
export function formatToolLabel(name: string, args?: unknown): string {
return formatToolLabelWith(EN_TOOL_LABELS, name, args);
}
function compactValue(value: unknown): string {
if (typeof value === "string") return value.replace(/\s+/g, " ").trim();
if (value === null || value === undefined) return "";