fix(ui): localize chat surfaces

This commit is contained in:
Vincent Koc
2026-07-12 13:45:11 +08:00
committed by Vincent Koc
parent 0faeb8e9c3
commit 27da133957
14 changed files with 216 additions and 117 deletions
@@ -50,6 +50,7 @@ describe("slash command browser import", () => {
'import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";',
'import type { CommandEntry } from "../../../../packages/gateway-protocol/src/index.js";',
'import { buildBuiltinChatCommands } from "../../../../src/auto-reply/commands-registry.shared.js";',
'import { t } from "../../i18n/index.ts";',
'import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts";',
]);
expect(importDeclarations(sharedRegistry)).toEqual([
+17 -7
View File
@@ -3,6 +3,7 @@
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import type { CommandEntry } from "../../../../packages/gateway-protocol/src/index.js";
import { buildBuiltinChatCommands } from "../../../../src/auto-reply/commands-registry.shared.js";
import { t } from "../../i18n/index.ts";
import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts";
export type SlashCommandCategory = "session" | "model" | "agents" | "tools";
@@ -15,6 +16,7 @@ export type SlashCommandDef = {
name: string;
aliases?: string[];
description: string;
descriptionKey?: string;
args?: string;
icon?: ChatIconName;
category?: SlashCommandCategory;
@@ -100,6 +102,7 @@ const UI_ONLY_COMMANDS: SlashCommandDef[] = [
key: "clear",
name: "clear",
description: "Clear chat history",
descriptionKey: "chat.commands.clearDescription",
icon: "trash",
category: "session",
executeLocal: true,
@@ -109,6 +112,7 @@ const UI_ONLY_COMMANDS: SlashCommandDef[] = [
key: "redirect",
name: "redirect",
description: "Abort and restart with a new message",
descriptionKey: "chat.commands.redirectDescription",
args: "<message>",
icon: "refresh",
category: "agents",
@@ -145,6 +149,10 @@ const CATEGORY_OVERRIDES: Partial<Record<string, SlashCommandCategory>> = {
queue: "model",
};
const COMMAND_DESCRIPTION_KEYS: Partial<Record<string, string>> = {
steer: "chat.commands.steerDescription",
};
const COMMAND_DESCRIPTION_OVERRIDES: Partial<Record<string, string>> = {
steer: "Inject a message into the active run",
};
@@ -235,6 +243,7 @@ function toSlashCommand(
name,
aliases: getSlashAliases(command).filter((alias) => alias !== name),
description: COMMAND_DESCRIPTION_OVERRIDES[command.key] ?? command.description,
descriptionKey: COMMAND_DESCRIPTION_KEYS[command.key],
args: COMMAND_ARGS_OVERRIDES[command.key] ?? formatArgs(command),
icon: mapIcon(command),
category: mapCategory(command),
@@ -428,12 +437,13 @@ export function resetSlashCommandsForTest(): void {
const CATEGORY_ORDER: SlashCommandCategory[] = ["session", "model", "tools", "agents"];
export const CATEGORY_LABELS: Record<SlashCommandCategory, string> = {
session: "Session",
model: "Model",
agents: "Agents",
tools: "Tools",
};
export function getSlashCommandCategoryLabel(category: SlashCommandCategory): string {
return t(`chat.commands.categories.${category}`);
}
export function getSlashCommandDescription(command: SlashCommandDef): string {
return command.descriptionKey ? t(command.descriptionKey) : command.description;
}
const TIER_ORDER: Record<SlashCommandTier, number> = {
essential: 0,
@@ -452,7 +462,7 @@ export function getSlashCommandCompletions(
(cmd) =>
cmd.name.startsWith(lower) ||
cmd.aliases?.some((alias) => normalizeLowercaseStringOrEmpty(alias).startsWith(lower)) ||
normalizeLowercaseStringOrEmpty(cmd.description).includes(lower),
normalizeLowercaseStringOrEmpty(getSlashCommandDescription(cmd)).includes(lower),
)
: SLASH_COMMANDS;
+16 -1
View File
@@ -1,6 +1,11 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from "vitest";
import { SLASH_COMMANDS } from "../../lib/chat/commands.ts";
import {
SLASH_COMMANDS,
getSlashCommandCategoryLabel,
getSlashCommandDescription,
type SlashCommandDef,
} from "../../lib/chat/commands.ts";
import { refreshSlashCommands, resetChatSlashCommandMetadataForTest } from "./chat-commands.ts";
afterEach(() => {
@@ -26,6 +31,16 @@ function expectRecordFields(value: unknown, label: string, expected: Record<stri
}
describe("refreshSlashCommands", () => {
it("resolves localized UI command metadata", () => {
const clear = SLASH_COMMANDS.find((entry) => entry.name === "clear");
const redirect = SLASH_COMMANDS.find((entry) => entry.name === "redirect");
expect(getSlashCommandDescription(clear as SlashCommandDef)).toBe("Clear chat history");
expect(getSlashCommandDescription(redirect as SlashCommandDef)).toBe(
"Abort and restart with a new message",
);
expect(getSlashCommandCategoryLabel("tools")).toBe("Tools");
});
it("exposes /learn through the browser fallback registry", () => {
expectRecordFields(requireCommandByName("learn"), "learn command", {
description: "Draft a reusable skill from recent work or named sources.",
+7 -4
View File
@@ -4,6 +4,7 @@ import {
isToolResultContentType,
resolveToolUseId,
} from "../../../../src/chat/tool-content.js";
import { t } from "../../i18n/index.ts";
import type {
ChatItem,
MessageGroup,
@@ -39,6 +40,8 @@ import { buildUserChatMessageContentBlocks } from "./user-message-content.ts";
export type BuildChatItemsProps = {
sessionKey: string;
/** Invalidates cached display copy when the active UI language changes. */
locale?: string;
messages: unknown[];
toolMessages: unknown[];
streamSegments: ChatStreamSegment[];
@@ -1149,12 +1152,11 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
typeof marker.id === "string"
? `divider:compaction:${marker.id}`
: `divider:compaction:${normalized.timestamp}:${i}`,
label: "Compacted history",
description:
"The compacted transcript is preserved as a checkpoint. Open session checkpoints to branch or restore from that compacted view.",
label: t("chat.compaction.label"),
description: t("chat.compaction.description"),
action: {
kind: "session-checkpoints",
label: "Open checkpoints",
label: t("chat.compaction.openCheckpoints"),
},
timestamp: normalized.timestamp ?? Date.now(),
});
@@ -1371,6 +1373,7 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
function sameChatItemsInput(previous: BuildChatItemsProps, next: BuildChatItemsProps): boolean {
return (
previous.sessionKey === next.sessionKey &&
previous.locale === next.locale &&
previous.messages === next.messages &&
previous.toolMessages === next.toolMessages &&
previous.streamSegments === next.streamSegments &&
+27
View File
@@ -16,6 +16,7 @@ import {
} from "../../components/markdown.ts";
import { i18n, t } from "../../i18n/index.ts";
import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts";
import { SLASH_COMMANDS } from "../../lib/chat/commands.ts";
import { createSessionCapability, type SessionCapability } from "../../lib/sessions/index.ts";
import {
createModelCatalog,
@@ -3395,6 +3396,32 @@ describe("chat slash menu accessibility", () => {
expect(announcementText).toBe(expectedAnnouncement);
});
it("uses the localized command description in the live announcement", async () => {
const clearCommand = SLASH_COMMANDS.find((command) => command.name === "clear");
if (!clearCommand) {
throw new Error("Expected the clear slash command");
}
const originalDescriptionKey = clearCommand.descriptionKey;
clearCommand.descriptionKey = "common.health";
await i18n.setLocale("zh-CN");
try {
let draft = "";
const onDraftChange = vi.fn((next: string) => {
draft = next;
});
let container = renderChatView({ draft, onDraftChange });
inputDraft(container, "/clear");
container = renderChatView({ draft, onDraftChange });
const status = container.querySelector<HTMLElement>("#chat-single-slash-active-announcement");
expect(status?.textContent?.trim()).toBe(`/clear ${t("common.health")}`);
} finally {
clearCommand.descriptionKey = originalDescriptionKey;
await i18n.setLocale("en");
}
});
it("wires fixed argument suggestions with command-and-argument option ids", () => {
let draft = "";
const onDraftChange = vi.fn((next: string) => {
+5 -5
View File
@@ -325,7 +325,7 @@ export function renderChat(props: ChatProps) {
class="chat-scroll-to-bottom"
type="button"
@click=${() => props.onScrollToBottom?.({ smooth: true })}
aria-label="Scroll to latest"
aria-label=${t("chat.actions.scrollToLatest")}
>
${icons.arrowDown}
</button>
@@ -378,12 +378,12 @@ export function renderChat(props: ChatProps) {
<span class="callout__content">${props.error}</span>
${props.onDismissError
? html`
<openclaw-tooltip content="Dismiss error">
<openclaw-tooltip .content=${t("chat.actions.dismissError")}>
<button
class="callout__dismiss"
type="button"
@click=${props.onDismissError}
aria-label="Dismiss error"
aria-label=${t("chat.actions.dismissError")}
>
${icons.x}
</button>
@@ -395,12 +395,12 @@ export function renderChat(props: ChatProps) {
: nothing}
${props.focusMode && props.onToggleFocusMode
? html`
<openclaw-tooltip content="Exit focus mode">
<openclaw-tooltip .content=${t("chat.actions.exitFocusMode")}>
<button
class="chat-focus-exit"
type="button"
@click=${props.onToggleFocusMode}
aria-label="Exit focus mode"
aria-label=${t("chat.actions.exitFocusMode")}
>
${icons.x}
</button>
+53 -34
View File
@@ -11,10 +11,11 @@ import "../../../components/tooltip.ts";
import { t } from "../../../i18n/index.ts";
import type { ChatAttachment, ChatQueueItem } from "../../../lib/chat/chat-types.ts";
import {
CATEGORY_LABELS,
SLASH_COMMANDS,
getHiddenCommandCount,
getSlashCommandCategoryLabel,
getSlashCommandCompletions,
getSlashCommandDescription,
type SlashCommandCategory,
type SlashCommandDef,
} from "../../../lib/chat/commands.ts";
@@ -442,7 +443,7 @@ function renderChatGoal(
${showActions && actions.onGoalEdit && goal.status !== "complete"
? renderChatGoalActionButton({
className: "agent-chat__goal-edit",
label: "Edit goal",
label: t("chat.goals.edit"),
icon: icons.penLine,
onClick: () => actions.onGoalEdit?.(goal),
})
@@ -450,7 +451,7 @@ function renderChatGoal(
${showActions && goal.status === "active"
? renderChatGoalActionButton({
className: "agent-chat__goal-pause",
label: "Pause goal",
label: t("chat.goals.pause"),
icon: icons.pause,
onClick: () => actions.onGoalCommand?.("/goal pause"),
})
@@ -458,7 +459,7 @@ function renderChatGoal(
${showActions && canResume
? renderChatGoalActionButton({
className: "agent-chat__goal-resume",
label: "Resume goal",
label: t("chat.goals.resume"),
icon: icons.play,
onClick: () => actions.onGoalCommand?.("/goal resume"),
})
@@ -466,7 +467,7 @@ function renderChatGoal(
${showActions
? renderChatGoalActionButton({
className: "agent-chat__goal-clear",
label: "Clear goal",
label: t("chat.goals.clear"),
icon: icons.trash,
onClick: () => actions.onGoalCommand?.("/goal clear"),
})
@@ -743,7 +744,7 @@ function getActiveSlashMenuOptionLabel(state: ChatComposerState): string {
return "";
}
const command = `/${cmd.name}${cmd.args ? ` ${cmd.args}` : ""}`;
return `${command} ${cmd.description}`;
return `${command} ${getSlashCommandDescription(cmd)}`;
}
function scrollActiveSlashMenuOptionIntoView(state: ChatComposerState, paneId: string): void {
@@ -801,10 +802,15 @@ function renderSlashMenu(
state.slashMenuArgItems.length > 0
) {
return html`
<div id=${listboxId} class="slash-menu" role="listbox" aria-label="Command arguments">
<div
id=${listboxId}
class="slash-menu"
role="listbox"
aria-label=${t("chat.commands.arguments")}
>
<div class="slash-menu-group">
<div class="slash-menu-group__label">
/${state.slashMenuCommand.name} ${state.slashMenuCommand.description}
/${state.slashMenuCommand.name} ${getSlashCommandDescription(state.slashMenuCommand)}
</div>
${state.slashMenuArgItems.map(
(arg, i) => html`
@@ -833,7 +839,9 @@ function renderSlashMenu(
)}
</div>
<div class="slash-menu-footer">
<kbd>↑↓</kbd> navigate <kbd>Tab</kbd> fill <kbd>Enter</kbd> run <kbd>Esc</kbd> close
<kbd>↑↓</kbd> ${t("chat.commands.navigate")} <kbd>Tab</kbd> ${t("chat.commands.fill")}
<kbd>Enter</kbd> ${t("chat.commands.run")} <kbd>Esc</kbd>
${t("chat.commands.close")}
</div>
</div>
`;
@@ -861,7 +869,7 @@ function renderSlashMenu(
for (const [cat, entries] of grouped) {
sections.push(html`
<div class="slash-menu-group">
<div class="slash-menu-group__label">${CATEGORY_LABELS[cat]}</div>
<div class="slash-menu-group__label">${getSlashCommandCategoryLabel(cat)}</div>
${entries.map(
({ cmd, globalIdx }) => html`
<div
@@ -882,11 +890,15 @@ function renderSlashMenu(
: nothing}
<span class="slash-menu-name">/${cmd.name}</span>
${cmd.args ? html`<span class="slash-menu-args">${cmd.args}</span>` : nothing}
<span class="slash-menu-desc">${cmd.description}</span>
<span class="slash-menu-desc">${getSlashCommandDescription(cmd)}</span>
${cmd.argOptions?.length
? html`<span class="slash-menu-badge">${cmd.argOptions.length} options</span>`
? html`<span class="slash-menu-badge"
>${t("chat.commands.optionCount", {
count: String(cmd.argOptions.length),
})}</span
>`
: cmd.executeLocal && !cmd.args
? html` <span class="slash-menu-badge">instant</span> `
? html` <span class="slash-menu-badge">${t("chat.commands.instant")}</span> `
: nothing}
</div>
`,
@@ -898,7 +910,7 @@ function renderSlashMenu(
const hiddenCount = state.slashMenuExpanded ? 0 : getHiddenCommandCount();
return html`
<div id=${listboxId} class="slash-menu" role="listbox" aria-label="Slash commands">
<div id=${listboxId} class="slash-menu" role="listbox" aria-label=${t("chat.commands.menu")}>
${sections}
${hiddenCount > 0
? html`<button
@@ -910,11 +922,15 @@ function renderSlashMenu(
updateSlashMenu(draft, requestUpdate, props);
}}
>
Show ${hiddenCount} more command${hiddenCount !== 1 ? "s" : ""}
${hiddenCount === 1
? t("chat.commands.showMoreOne")
: t("chat.commands.showMoreMany", { count: String(hiddenCount) })}
</button>`
: nothing}
<div class="slash-menu-footer">
<kbd>↑↓</kbd> navigate <kbd>Tab</kbd> fill <kbd>Enter</kbd> select <kbd>Esc</kbd> close
<kbd>↑↓</kbd> ${t("chat.commands.navigate")} <kbd>Tab</kbd> ${t("chat.commands.fill")}
<kbd>Enter</kbd> ${t("chat.commands.select")} <kbd>Esc</kbd>
${t("chat.commands.close")}
</div>
</div>
`;
@@ -989,7 +1005,9 @@ function renderChatQueueItem(item: ChatQueueItem, props: ChatQueueProps) {
${failed ? icons.alertTriangle : icons.clock}
</span>
${steered
? html`<span class="chat-queue__badge chat-queue__badge--steered">Steered</span>`
? html`<span class="chat-queue__badge chat-queue__badge--steered"
>${t("chat.queue.steered")}</span
>`
: nothing}
${stateLabel ? html`<span class="chat-queue__badge">${stateLabel}</span>` : nothing}
<span class="chat-queue__text" title=${text}>${text}</span>
@@ -1012,22 +1030,22 @@ function renderChatQueueItem(item: ChatQueueItem, props: ChatQueueProps) {
<button
class="chat-queue__steer"
type="button"
aria-label="Steer queued message"
aria-label=${t("chat.queue.steerQueuedMessage")}
@click=${() => props.onQueueSteer?.(item.id)}
>
${icons.cornerDownRight}
<span>Steer</span>
<span>${t("chat.queue.steer")}</span>
</button>
`
: nothing}
${busy
? nothing
: html`
<openclaw-tooltip content="Remove queued message">
<openclaw-tooltip .content=${t("chat.queue.removeQueuedMessage")}>
<button
class="chat-queue__remove"
type="button"
aria-label="Remove queued message"
aria-label=${t("chat.queue.removeQueuedMessage")}
@click=${() => props.onQueueRemove(item.id)}
>
${icons.x}
@@ -1251,11 +1269,11 @@ function renderAttachmentPreview(props: ChatAttachmentControlsProps) {
</div>
</openclaw-tooltip>
`}
<openclaw-tooltip content="Remove attachment">
<openclaw-tooltip .content=${t("chat.composer.removeAttachment")}>
<button
class="chat-attachment-remove"
type="button"
aria-label="Remove attachment"
aria-label=${t("chat.composer.removeAttachment")}
@click=${() => {
const next = (props.attachments ?? []).filter((a) => a.id !== att.id);
releaseChatAttachmentPayload(att.id);
@@ -1292,12 +1310,13 @@ export function renderChatRunStatusIndicator(status: ComposerRunStatus | null |
if (elapsed >= CHAT_RUN_STATUS_TOAST_DURATION_MS) {
return nothing;
}
const interrupted = t("chat.composer.runInterrupted");
return html`
<span
class="agent-chat__run-status agent-chat__run-status--interrupted"
aria-label="Run status: Interrupted"
aria-label=${t("chat.composer.runStatus", { status: interrupted })}
>
${icons.stop}<span class="agent-chat__run-status-label">Interrupted</span>
${icons.stop}<span class="agent-chat__run-status-label">${interrupted}</span>
</span>
`;
}
@@ -1857,7 +1876,7 @@ export function renderContextNotice(
? "context-ring__action--busy"
: ""}"
type="button"
aria-label="Compact recommended session context"
aria-label=${t("chat.composer.compactRecommendedContext")}
?disabled=${compactDisabled}
@click=${(event: Event) => {
event.preventDefault();
@@ -2109,12 +2128,12 @@ export function renderChatComposer(props: ChatComposerProps) {
const assistantName = props.assistantName || "OpenClaw";
const inProgressLabel =
submittedProgress?.sendState === "waiting-model"
? "Preparing model..."
? t("chat.composer.preparingModel")
: props.stream !== null
? `${assistantName} is responding...`
? t("chat.composer.responding", { name: assistantName })
: props.sending || submittedProgress
? "Sending message..."
: `${assistantName} is working...`;
? t("chat.composer.sendingMessage")
: t("chat.composer.working", { name: assistantName });
// Persistent sr-only live region: run phases are otherwise conveyed only
// visually (thread spark, content arriving, interrupted toast).
const runStatusAnnouncement =
@@ -2123,8 +2142,8 @@ export function renderChatComposer(props: ChatComposerProps) {
: composerRunStatus.phase === "in-progress"
? inProgressLabel
: composerRunStatus.phase === "done"
? "Done"
: "Interrupted";
? t("chat.composer.runDone")
: t("chat.composer.runInterrupted");
const requestUpdate = props.onRequestUpdate ?? (() => {});
const sendShortcut = normalizeChatSendShortcut(props.sendShortcut);
@@ -2423,8 +2442,8 @@ export function renderChatComposer(props: ChatComposerProps) {
type="button"
class="chat-reply-preview__dismiss"
@click=${() => props.onClearReply?.()}
aria-label="Cancel reply"
title="Cancel reply"
aria-label=${t("chat.composer.cancelReply")}
title=${t("chat.composer.cancelReply")}
>
${icons.x}
</button>
+13 -11
View File
@@ -94,8 +94,8 @@ export function formatChatTimestampForDisplay(timestamp: number): ChatTimestampD
const date = new Date(timestamp);
if (!Number.isFinite(date.getTime())) {
return {
label: "Unknown date",
title: "Unknown date",
label: t("chat.messages.unknownDate"),
title: t("chat.messages.unknownDate"),
dateTime: "",
};
}
@@ -133,7 +133,7 @@ const CHAT_RELATIVE_TIMESTAMP_FUTURE_SKEW_MS = 2 * 60 * 1000;
export function formatChatRelativeTimestampLabel(timestamp: number, nowMs = Date.now()): string {
const date = new Date(timestamp);
if (!Number.isFinite(date.getTime())) {
return "Unknown date";
return t("chat.messages.unknownDate");
}
const ageMs = nowMs - date.getTime();
// Derive from ageMs so the injected clock stays the single time source.
@@ -815,7 +815,7 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup
: nothing}
</div>
<div class="chat-group-footer">
<span class="chat-sender-name">Activity</span>
<span class="chat-sender-name">${t("chat.messages.activity")}</span>
${renderChatTimestamp(group.timestamp)}
${opts.onDelete ? renderDeleteButton(opts.onDelete, "right") : nothing}
</div>
@@ -1117,10 +1117,10 @@ function placeDeleteConfirmPopover(
function renderDeleteButton(onDelete: () => void, side: DeleteConfirmSide) {
return html`
<span class="chat-delete-wrap">
<openclaw-tooltip content="Delete">
<openclaw-tooltip .content=${t("common.delete")}>
<button
class="chat-group-delete"
aria-label="Delete message"
aria-label=${t("chat.messages.deleteMessage")}
@click=${(e: Event) => {
if (shouldSkipDeleteConfirm()) {
onDelete();
@@ -1751,7 +1751,9 @@ function renderAssistantAttachments(
>${availability.status === "checking" ? "Checking..." : "Unavailable"}</span
>`
: attachment.isVoiceNote
? html`<span class="chat-assistant-attachment-badge">Voice note</span>`
? html`<span class="chat-assistant-attachment-badge"
>${t("chat.messages.voiceNote")}</span
>`
: nothing}
</div>
${attachmentUrl
@@ -1917,11 +1919,11 @@ function renderExpandButton(
},
) {
return html`
<openclaw-tooltip content="Open in canvas">
<openclaw-tooltip .content=${t("chat.messages.openInCanvas")}>
<button
class="btn btn--xs chat-expand-btn"
type="button"
aria-label="Open in canvas"
aria-label=${t("chat.messages.openInCanvas")}
@click=${() =>
onOpenSidebar({
kind: "markdown",
@@ -2147,10 +2149,10 @@ function renderGroupedMessage(
: `${toolNames.slice(0, 2).join(", ")} +${toolNames.length - 2} more`;
const toolPreview = markdown ? (formatCollapsedToolPreviewText(markdown) ?? "") : "";
const toolMessageLabelRaw = toolMessageHasError
? "Tool error"
? t("chat.toolCards.toolError")
: singleToolDisplay && !markdown && !hasImages
? singleToolDisplay.label
: "Tool output";
: t("chat.toolCards.toolOutput");
const toolMessageLabel =
formatCollapsedToolSummaryText(toolMessageLabelRaw) ?? toolMessageLabelRaw;
const toolSummaryLabel = formatDistinctCollapsedToolSummaryText(
@@ -484,7 +484,9 @@ function renderChatModelReasoningSelect(params: {
<span class="chat-controls__model-option-title">
<span class="chat-controls__model-option-name">${modelLabel}</span>
${entry.isDefault
? html`<span class="chat-controls__model-default-label">Default</span>`
? html`<span class="chat-controls__model-default-label"
>${t("chat.modelControls.default")}</span
>`
: ""}
</span>
<span class="chat-controls__model-option-provider">
@@ -605,7 +607,9 @@ function renderChatModelReasoningSelect(params: {
${showReasoning
? html`
<div class="chat-controls__reasoning-head">
<span class="chat-controls__inline-select-section-label">Reasoning</span>
<span class="chat-controls__inline-select-section-label"
>${t("chat.modelControls.reasoning")}</span
>
<span class="chat-controls__reasoning-state">
<span
class="chat-controls__reasoning-value ${hasThinkingOverride
@@ -718,7 +722,9 @@ function renderChatModelReasoningSelect(params: {
`
: ""}
<div class="chat-controls__speed-row">
<span class="chat-controls__inline-select-section-label">Speed</span>
<span class="chat-controls__inline-select-section-label"
>${t("chat.modelControls.speed")}</span
>
<openclaw-tooltip .content=${speedTooltip}>
<button
class="chat-controls__speed-toggle ${fastMode.active
+12 -11
View File
@@ -5,6 +5,7 @@ import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { icons } from "../../../components/icons.ts";
import { toSanitizedMarkdownHtml } from "../../../components/markdown.ts";
import "../../../components/tooltip.ts";
import { t } from "../../../i18n/index.ts";
import { buildSideChatFollowUpCommand } from "../../../lib/chat/side-question.ts";
import type { ChatSideResult, ChatSideResultPending } from "../../../lib/chat/side-result.ts";
import { detectTextDirection } from "../../../lib/text-direction.ts";
@@ -50,7 +51,7 @@ function renderSideChatPendingTurn(pending: ChatSideResultPending): TemplateResu
return html`
<article class="chat-side-chat__turn chat-side-chat__turn--pending">
<div class="chat-side-chat__question" dir=${detectTextDirection(question)}>${question}</div>
<div class="chat-side-chat__thinking">Thinking</div>
<div class="chat-side-chat__thinking">${t("chat.sideChat.thinking")}</div>
</article>
`;
}
@@ -92,28 +93,28 @@ export function renderSideChatPanel(props: SideChatPanelProps): TemplateResult |
input.value = "";
};
return html`
<section class="chat-side-chat" role="dialog" aria-label="Side chat">
<section class="chat-side-chat" role="dialog" aria-label=${t("chat.sideChat.title")}>
<header class="chat-side-chat__header">
<div class="chat-side-chat__heading">
<h2 class="chat-side-chat__title">Side chat</h2>
<span class="chat-side-chat__meta">Not saved to chat history</span>
<h2 class="chat-side-chat__title">${t("chat.sideChat.title")}</h2>
<span class="chat-side-chat__meta">${t("chat.sideChat.notSaved")}</span>
</div>
<div class="chat-side-chat__actions">
<openclaw-tooltip content="Clear side chat">
<openclaw-tooltip .content=${t("chat.sideChat.clear")}>
<button
class="btn btn--ghost btn--icon chat-icon-btn"
type="button"
aria-label="Clear side chat"
aria-label=${t("chat.sideChat.clear")}
@click=${() => props.onClear?.()}
>
${icons.trash}
</button>
</openclaw-tooltip>
<openclaw-tooltip content="Close side chat">
<openclaw-tooltip .content=${t("chat.sideChat.close")}>
<button
class="btn btn--ghost btn--icon chat-icon-btn"
type="button"
aria-label="Close side chat"
aria-label=${t("chat.sideChat.close")}
@click=${() => props.onClose?.()}
>
${icons.x}
@@ -133,8 +134,8 @@ export function renderSideChatPanel(props: SideChatPanelProps): TemplateResult |
<input
class="chat-side-chat__input"
type="text"
placeholder=${pending ? "Thinking" : "Follow up…"}
aria-label="Follow up in side chat"
placeholder=${pending ? t("chat.sideChat.thinking") : t("chat.sideChat.followUp")}
aria-label=${t("chat.sideChat.followUpLabel")}
.disabled=${pending != null}
@keydown=${(event: KeyboardEvent) => {
if (event.key !== "Enter" || event.isComposing) {
@@ -147,7 +148,7 @@ export function renderSideChatPanel(props: SideChatPanelProps): TemplateResult |
<button
class="btn btn--ghost btn--icon chat-icon-btn chat-side-chat__send"
type="button"
aria-label="Send follow-up"
aria-label=${t("chat.sideChat.sendFollowUp")}
.disabled=${pending != null}
@click=${(event: MouseEvent) => {
const input = (event.currentTarget as HTMLElement)
+32 -27
View File
@@ -286,11 +286,11 @@ function renderFileSidebarContent(
<div class="sidebar-file-view__path-bar">
<div class="sidebar-file-view__path-field">
<span class="sidebar-file-view__path" title=${content.path}>${content.path}</span>
<openclaw-tooltip content="Copy path">
<openclaw-tooltip .content=${t("chat.detailPanel.copyPath")}>
<button
class="btn btn--sm sidebar-file-view__action"
type="button"
aria-label="Copy path"
aria-label=${t("chat.detailPanel.copyPath")}
@click=${() => void copyToClipboard(content.path)}
>
${icons.copy}
@@ -316,17 +316,17 @@ function renderFileSidebarContent(
?disabled=${controls.saving}
@click=${controls.onDiscard}
>
Discard
${t("chat.detailPanel.discard")}
</button>
`
: html`
${content.edit
? html`
<openclaw-tooltip content="Edit file">
<openclaw-tooltip .content=${t("chat.detailPanel.editFile")}>
<button
class="btn btn--sm sidebar-file-view__action"
type="button"
aria-label="Edit file"
aria-label=${t("chat.detailPanel.editFile")}
?disabled=${controls.loadingEditor}
@click=${controls.onEdit}
>
@@ -335,11 +335,11 @@ function renderFileSidebarContent(
</openclaw-tooltip>
`
: nothing}
<openclaw-tooltip content="Search in file">
<openclaw-tooltip .content=${t("chat.detailPanel.searchInFile")}>
<button
class="btn btn--sm sidebar-file-view__action"
type="button"
aria-label="Search in file"
aria-label=${t("chat.detailPanel.searchInFile")}
aria-pressed=${String(controls.searchOpen)}
@click=${controls.onToggleSearch}
>
@@ -348,11 +348,11 @@ function renderFileSidebarContent(
</openclaw-tooltip>
${controls.onReveal
? html`
<openclaw-tooltip content="Show in Files">
<openclaw-tooltip .content=${t("chat.detailPanel.showInFiles")}>
<button
class="btn btn--sm sidebar-file-view__action"
type="button"
aria-label="Show in Files"
aria-label=${t("chat.detailPanel.showInFiles")}
@click=${() => controls.onReveal?.(content.path)}
>
${icons.folder}
@@ -417,8 +417,8 @@ function renderFileSidebarContent(
<div class="file-view__search">
<input
type="search"
aria-label="Search in file"
placeholder="Search"
aria-label=${t("chat.detailPanel.searchInFile")}
placeholder=${t("common.search")}
.value=${controls.query}
@input=${(event: Event) =>
controls.onSearchInput((event.currentTarget as HTMLInputElement).value)}
@@ -430,7 +430,7 @@ function renderFileSidebarContent(
<button
class="btn btn--sm file-view__search-action file-view__search-action--previous"
type="button"
aria-label="Previous match"
aria-label=${t("chat.detailPanel.previousMatch")}
?disabled=${controls.matches.length === 0}
@click=${controls.onPreviousMatch}
>
@@ -439,7 +439,7 @@ function renderFileSidebarContent(
<button
class="btn btn--sm file-view__search-action"
type="button"
aria-label="Next match"
aria-label=${t("chat.detailPanel.nextMatch")}
?disabled=${controls.matches.length === 0}
@click=${controls.onNextMatch}
>
@@ -465,7 +465,7 @@ function renderFileSidebarContent(
?disabled=${controls.saving}
@click=${controls.onReload}
>
Reload
${t("common.reload")}
</button>
<button
class="btn btn--sm"
@@ -473,7 +473,7 @@ function renderFileSidebarContent(
?disabled=${controls.saving}
@click=${controls.onOverwrite}
>
Overwrite
${t("chat.detailPanel.overwrite")}
</button>
</div>
`
@@ -484,7 +484,7 @@ function renderFileSidebarContent(
<div class="file-view">
${keyed(controls?.mountKey ?? content, html`<div class="file-view__mount"></div>`)}
${controls?.loadingEditor
? html`<div class="file-view__loading muted">Loading…</div>`
? html`<div class="file-view__loading muted">${t("common.loading")}</div>`
: nothing}
</div>
${controls?.editing
@@ -492,7 +492,7 @@ function renderFileSidebarContent(
: html`
<div class="sidebar-file-view__footer">
<button @click=${onViewRawText} class="btn btn--sm" type="button">
View Raw Text
${t("chat.detailPanel.viewRawText")}
</button>
</div>
`}
@@ -554,8 +554,13 @@ export function renderMarkdownSidebar(props: MarkdownSidebarProps) {
<div class="sidebar-panel">
<div class="sidebar-header">
<div class="sidebar-title">${title}</div>
<openclaw-tooltip content="Close sidebar">
<button @click=${props.onClose} class="btn" type="button" aria-label="Close sidebar">
<openclaw-tooltip .content=${t("chat.detailPanel.close")}>
<button
@click=${props.onClose}
class="btn"
type="button"
aria-label=${t("chat.detailPanel.close")}
>
${icons.x}
</button>
</openclaw-tooltip>
@@ -572,7 +577,7 @@ export function renderMarkdownSidebar(props: MarkdownSidebarProps) {
type="button"
style="margin-top: 12px;"
>
View Raw Text
${t("chat.detailPanel.viewRawText")}
</button>
`
: nothing}
@@ -605,7 +610,7 @@ export function renderMarkdownSidebar(props: MarkdownSidebarProps) {
? html`
<div style="margin-top: 12px;">
<button @click=${props.onViewRawText} class="btn" type="button">
View Raw Text
${t("chat.detailPanel.viewRawText")}
</button>
</div>
`
@@ -627,7 +632,7 @@ export function renderMarkdownSidebar(props: MarkdownSidebarProps) {
? html`
<div style="margin-top: 12px;">
<button @click=${props.onViewRawText} class="btn" type="button">
View Raw Text
${t("chat.detailPanel.viewRawText")}
</button>
</div>
`
@@ -640,14 +645,14 @@ export function renderMarkdownSidebar(props: MarkdownSidebarProps) {
<div class="sidebar-markdown-shell__intro">
<div class="sidebar-markdown-shell__eyebrow">
${icons.scrollText}
<span>Rendered Markdown</span>
<span>${t("chat.detailPanel.renderedMarkdown")}</span>
</div>
<div class="sidebar-markdown-shell__hint">
Sanitized rich-text preview for quick reading.
${t("chat.detailPanel.renderedMarkdownHint")}
</div>
</div>
<button @click=${props.onViewRawText} class="btn btn--sm" type="button">
View Raw Text
${t("chat.detailPanel.viewRawText")}
</button>
</div>
${markdownHtml
@@ -658,12 +663,12 @@ export function renderMarkdownSidebar(props: MarkdownSidebarProps) {
`
: html`
<div class="sidebar-markdown-empty">
No previewable markdown content.
${t("chat.detailPanel.noPreviewableMarkdown")}
</div>
`}
</section>
`
: html` <div class="muted">No content available</div> `}
: html` <div class="muted">${t("chat.detailPanel.noContent")}</div> `}
</div>
</div>
`;
+12 -8
View File
@@ -14,6 +14,7 @@ import {
handleMarkdownCodeBlockCopy,
markdownFileLinkFromEvent,
} from "../../../components/markdown.ts";
import { i18n, t } from "../../../i18n/index.ts";
import { CHAT_HISTORY_RENDER_LIMIT } from "../../../lib/chat/chat-types.ts";
import type { ChatQueueItem, ChatStreamSegment } from "../../../lib/chat/chat-types.ts";
import { extractTextCached } from "../../../lib/chat/message-extract.ts";
@@ -400,18 +401,18 @@ export function renderChatSearchBar(
${icons.search}
<input
type="text"
placeholder="Search messages..."
aria-label="Search messages"
placeholder=${t("chat.thread.searchPlaceholder")}
aria-label=${t("chat.thread.search")}
.value=${state.searchQuery}
@input=${(event: Event) => {
state.searchQuery = (event.target as HTMLInputElement).value;
requestUpdate();
}}
/>
<openclaw-tooltip content="Close search">
<openclaw-tooltip .content=${t("chat.thread.closeSearch")}>
<button
class="btn btn--ghost"
aria-label="Close search"
aria-label=${t("chat.thread.closeSearch")}
@click=${() => {
state.searchOpen = false;
state.searchQuery = "";
@@ -489,10 +490,10 @@ export function renderChatPinnedMessages(
<span class="agent-chat__pinned-text"
>${truncateUtf16Safe(text, 100)}${text.length > 100 ? "..." : ""}</span
>
<openclaw-tooltip content="Unpin">
<openclaw-tooltip .content=${t("chat.thread.unpin")}>
<button
class="btn btn--ghost"
aria-label="Unpin"
aria-label=${t("chat.thread.unpin")}
@click=${() => {
pinned.unpin(index);
requestUpdate();
@@ -683,7 +684,7 @@ function handleChatContextMenu(event: MouseEvent, props: ChatThreadProps) {
function renderLoadingSkeleton() {
return html`
<div class="chat-loading-skeleton" aria-label="Loading chat">
<div class="chat-loading-skeleton" aria-label=${t("chat.thread.loading")}>
<div class="chat-line assistant">
<div class="chat-msg">
<div class="chat-bubble">
@@ -749,8 +750,10 @@ export function renderChatThread(props: ChatThreadProps) {
};
const historyRenderLimit = resolveChatHistoryRenderWindow(props);
const deleted = getDeletedMessages(props.sessionKey);
const locale = i18n.getLocale();
const chatItems = buildCachedChatItems({
sessionKey: props.sessionKey,
locale,
messages: props.messages,
toolMessages: props.toolMessages,
streamSegments: props.streamSegments,
@@ -828,11 +831,12 @@ export function renderChatThread(props: ChatThreadProps) {
${showLoadingSkeleton ? renderLoadingSkeleton() : nothing}
${isEmpty && !state.searchOpen ? renderWelcomeState(props) : nothing}
${isEmpty && state.searchOpen
? html` <div class="agent-chat__empty">No matching messages</div> `
? html` <div class="agent-chat__empty">${t("chat.thread.noMatches")}</div> `
: nothing}
${guard(
[
chatItems,
locale,
deletedChatItemsSignature(deleted, chatItems),
stableBooleanMapSignature(expandedToolCards),
getAssistantAttachmentAvailabilityRenderVersion(),
@@ -29,6 +29,7 @@ vi.mock("../tool-display.ts", () => ({
}));
import { resolveMcpAppSandboxUrl } from "../../../components/mcp-app-view.ts";
import { t } from "../../../i18n/index.ts";
import {
formatDistinctCollapsedToolSummaryText,
formatCollapsedToolPreviewText,
@@ -774,6 +775,9 @@ describe("tool-cards", () => {
const sidebarButton = container.querySelector<HTMLButtonElement>(".chat-tool-card__action-btn");
expect(sidebarButton).toBeInstanceOf(HTMLButtonElement);
expect([...sidebarButton!.classList]).toEqual(["chat-tool-card__action-btn"]);
const tooltip = sidebarButton!.parentElement as HTMLElement & { content?: string };
expect(tooltip.content).toBe(t("chat.toolCards.openDetails"));
expect(sidebarButton!.getAttribute("aria-label")).toBe(t("chat.toolCards.openDetails"));
sidebarButton!.click();
const sidebar = requireFirstMockArg(onOpenSidebar, "sidebar open");
@@ -256,7 +256,9 @@ export function renderToolPreview(
return html`
<div class="chat-tool-card__preview" data-kind="canvas" data-surface=${surface}>
<div class="chat-tool-card__preview-header">
<span class="chat-tool-card__preview-label">${preview.title?.trim() || "Canvas"}</span>
<span class="chat-tool-card__preview-label"
>${preview.title?.trim() || t("chat.toolCards.canvas")}</span
>
</div>
<div class="chat-tool-card__preview-panel" data-side="canvas">
${preview.mcpApp
@@ -264,10 +266,10 @@ export function renderToolPreview(
.sessionKey=${options?.sessionKey ?? ""}
.viewId=${preview.mcpApp.viewId}
.height=${preview.preferredHeight ?? 600}
.title=${preview.title?.trim() || "MCP App"}
.title=${preview.title?.trim() || t("mcpApp.title")}
></mcp-app-view>`
: renderPreviewFrame({
title: preview.title?.trim() || "Canvas",
title: preview.title?.trim() || t("chat.toolCards.canvas"),
src: resolveCanvasIframeUrl(
preview.url,
options?.canvasPluginSurfaceUrl,
@@ -643,7 +645,7 @@ function renderToolWorkspaceFilePath(
<button
class="chat-tool-card__detail chat-tool-card__detail-link"
type="button"
title="Open file"
title=${t("chat.toolCards.openFile")}
@click=${() => onOpenWorkspaceFile({ path })}
>
${label}
@@ -845,12 +847,12 @@ export function renderExpandedToolCardContent(
const sidebarAction = canOpenSidebar
? html`
<div class="chat-tool-card__actions">
<openclaw-tooltip content="Open in the side panel">
<openclaw-tooltip content=${t("chat.toolCards.openDetails")}>
<button
class="chat-tool-card__action-btn"
type="button"
@click=${() => onOpenSidebar?.(sidebarActionContent)}
aria-label="Open tool details in side panel"
aria-label=${t("chat.toolCards.openDetails")}
>
<span class="chat-tool-card__action-icon">${icons.panelRightOpen}</span>
</button>