From 649e6f096d3102ff229cf9e72348cbc59c87ddd5 Mon Sep 17 00:00:00 2001 From: lizhihua <275091674@qq.com> Date: Tue, 17 Feb 2026 15:08:27 +0800 Subject: [PATCH] feat: add i18n text --- .../session/artifact-markdown-editor.tsx | 55 +- .../components/session/artifacts-panel.tsx | 9 +- .../src/app/components/session/composer.tsx | 73 +- .../app/components/session/context-panel.tsx | 37 +- .../app/components/session/inbox-panel.tsx | 49 +- .../app/components/session/message-list.tsx | 11 +- .../src/app/components/session/minimap.tsx | 5 +- .../src/app/components/session/sidebar.tsx | 55 +- .../app/src/app/components/status-bar.tsx | 35 +- packages/app/src/app/pages/config.tsx | 136 ++-- packages/app/src/app/pages/dashboard.tsx | 141 ++-- packages/app/src/app/pages/extensions.tsx | 21 +- packages/app/src/app/pages/identities.tsx | 231 +++--- packages/app/src/app/pages/onboarding.tsx | 8 +- packages/app/src/app/pages/plugins.tsx | 39 +- packages/app/src/app/pages/scheduled.tsx | 228 +++--- packages/app/src/app/pages/session.tsx | 262 ++++--- packages/app/src/app/pages/settings.tsx | 434 +++++----- packages/app/src/app/pages/skills.tsx | 86 +- packages/app/src/i18n/locales/en.ts | 686 +++++++++++++++- packages/app/src/i18n/locales/zh.ts | 739 +++++++++++++++++- 21 files changed, 2381 insertions(+), 959 deletions(-) diff --git a/packages/app/src/app/components/session/artifact-markdown-editor.tsx b/packages/app/src/app/components/session/artifact-markdown-editor.tsx index 1370194bc..96dea4a73 100644 --- a/packages/app/src/app/components/session/artifact-markdown-editor.tsx +++ b/packages/app/src/app/components/session/artifact-markdown-editor.tsx @@ -1,4 +1,5 @@ import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; +import { t } from "../../../i18n"; import { FileText, RefreshCcw, Save, X } from "lucide-solid"; import Button from "../button"; @@ -35,13 +36,13 @@ export default function ArtifactMarkdownEditor(props: ArtifactMarkdownEditorProp const [pendingReason, setPendingReason] = createSignal<"switch" | null>(null); const path = createMemo(() => props.path?.trim() ?? ""); - const title = createMemo(() => (path() ? basename(path()) : "Artifact")); + const title = createMemo(() => (path() ? basename(path()) : t("session.editor_default_title"))); const dirty = createMemo(() => draft() !== original()); const canWrite = createMemo(() => Boolean(props.client && props.workspaceId)); const canSave = createMemo(() => dirty() && !saving() && canWrite()); const writeDisabledReason = createMemo(() => { if (canWrite()) return null; - return "Connect to an OpenWork server worker to edit files."; + return t("session.editor_connect_hint"); }); const resetState = () => { @@ -69,7 +70,7 @@ export default function ArtifactMarkdownEditor(props: ArtifactMarkdownEditorProp } if (!target) return; if (!isMarkdown(target)) { - setError("Only markdown files are supported."); + setError(t("session.editor_toast_markdown_only")); return; } @@ -100,7 +101,7 @@ export default function ArtifactMarkdownEditor(props: ArtifactMarkdownEditorProp result = (await client.readWorkspaceFile(workspaceId, actualPath)) as OpenworkWorkspaceFileContent; } catch (second) { if (second instanceof OpenworkServerError && second.status === 404) { - throw new OpenworkServerError(404, "file_not_found", "File not found (workspace root or outbox)."); + throw new OpenworkServerError(404, "file_not_found", t("session.editor_toast_not_found")); } throw second; } @@ -112,7 +113,7 @@ export default function ArtifactMarkdownEditor(props: ArtifactMarkdownEditorProp setResolvedPath(actualPath); setBaseUpdatedAt(typeof result.updatedAt === "number" ? result.updatedAt : null); } catch (err) { - const message = err instanceof Error ? err.message : "Failed to load file"; + const message = err instanceof Error ? err.message : t("session.editor_toast_load_failed"); setError(message); setLoadedPath(target); } finally { @@ -125,11 +126,11 @@ export default function ArtifactMarkdownEditor(props: ArtifactMarkdownEditorProp const workspaceId = props.workspaceId; const target = resolvedPath() ?? path(); if (!client || !workspaceId || !target) { - props.onToast?.("Cannot save: OpenWork server not connected"); + props.onToast?.(t("session.editor_toast_save_connect")); return; } if (!isMarkdown(target)) { - props.onToast?.("Only markdown files are supported"); + props.onToast?.(t("session.editor_toast_markdown_only")); return; } if (!dirty()) return; @@ -158,7 +159,7 @@ export default function ArtifactMarkdownEditor(props: ArtifactMarkdownEditorProp setConfirmOverwrite(true); return; } - const message = err instanceof Error ? err.message : "Failed to save"; + const message = err instanceof Error ? err.message : t("session.editor_toast_save_failed"); setError(message); props.onToast?.(message); } finally { @@ -183,7 +184,7 @@ export default function ArtifactMarkdownEditor(props: ArtifactMarkdownEditorProp return; } // Reload is destructive; reuse the close-discard banner semantics. - setError("Discard changes to reload from disk (close and reopen), or save first."); + setError(t("session.editor_toast_reload_discard")); }; createEffect(() => { @@ -240,7 +241,7 @@ export default function ArtifactMarkdownEditor(props: ArtifactMarkdownEditorProp
{title()}
- Unsaved + {t("session.editor_unsaved")} @@ -256,26 +257,26 @@ export default function ArtifactMarkdownEditor(props: ArtifactMarkdownEditorProp class="text-xs h-9 py-0 px-3" onClick={requestReload} disabled={loading() || saving()} - title="Reload from disk" + title={t("session.editor_reload_tooltip")} > - Reload + {t("session.editor_reload")} @@ -300,10 +301,10 @@ export default function ArtifactMarkdownEditor(props: ArtifactMarkdownEditorProp
-
File changed since load. Overwrite anyway?
+
{t("session.editor_overwrite_prompt")}
@@ -321,10 +322,10 @@ export default function ArtifactMarkdownEditor(props: ArtifactMarkdownEditorProp
-
Discard unsaved changes and close?
+
{t("session.editor_discard_prompt")}
@@ -344,7 +345,7 @@ export default function ArtifactMarkdownEditor(props: ArtifactMarkdownEditorProp
- Switch to {pendingPath()} + {t("session.editor_switch_prompt").replace("{path}", pendingPath() ?? "")}
@@ -383,7 +384,7 @@ export default function ArtifactMarkdownEditor(props: ArtifactMarkdownEditorProp value={draft()} onChange={setDraft} placeholder="" - ariaLabel="Artifact editor" + ariaLabel={t("session.editor_aria_label")} class="h-full" autofocus /> diff --git a/packages/app/src/app/components/session/artifacts-panel.tsx b/packages/app/src/app/components/session/artifacts-panel.tsx index 259d977e4..bc9a082f4 100644 --- a/packages/app/src/app/components/session/artifacts-panel.tsx +++ b/packages/app/src/app/components/session/artifacts-panel.tsx @@ -1,4 +1,5 @@ import { For, Show, createMemo, createSignal } from "solid-js"; +import { t } from "../../../i18n"; import { Paperclip } from "lucide-solid"; export type ArtifactsPanelProps = { @@ -99,7 +100,7 @@ export default function ArtifactsPanel(props: ArtifactsPanelProps) {
- Artifacts + {t("session.artifacts_title")}
@@ -111,7 +112,7 @@ export default function ArtifactsPanel(props: ArtifactsPanelProps) {
0} - fallback={
No artifacts yet.
} + fallback={
{t("session.artifacts_empty")}
} > {(artifact) => { @@ -123,7 +124,7 @@ export default function ArtifactsPanel(props: ArtifactsPanelProps) { const openable = () => (md() ? canOpenMarkdown() : img() ? canOpenImage() : false); const tooltip = () => { if (md()) return display(); - if (img() && !canOpenImage()) return `${display()} (image preview coming soon)`; + if (img() && !canOpenImage()) return `${display()} (${t("session.artifacts_image_preview_soon")})`; return display(); }; return ( @@ -173,7 +174,7 @@ export default function ArtifactsPanel(props: ArtifactsPanelProps) { class="w-full mt-1 rounded-lg px-2 py-1.5 text-xs text-dls-secondary hover:text-dls-text hover:bg-dls-active transition-colors" onClick={() => setShowAll((prev) => !prev)} > - {showAll() ? "Show fewer" : `Show ${hiddenCount()} more`} + {showAll() ? t("session.artifacts_show_fewer") : t("session.artifacts_show_more").replace("{count}", String(hiddenCount()))}
diff --git a/packages/app/src/app/components/session/composer.tsx b/packages/app/src/app/components/session/composer.tsx index a10eba0ac..04fdfcf5a 100644 --- a/packages/app/src/app/components/session/composer.tsx +++ b/packages/app/src/app/components/session/composer.tsx @@ -1,5 +1,6 @@ import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"; import type { Agent } from "@opencode-ai/sdk/v2/client"; +import { t } from "../../../i18n"; import fuzzysort from "fuzzysort"; import { ArrowUp, AtSign, Check, ChevronDown, File as FileIcon, Paperclip, Square, Terminal, X, Zap } from "lucide-solid"; @@ -132,12 +133,12 @@ const clamp = (value: number, min: number, max: number) => Math.min(max, Math.ma const normalizeText = (value: string) => value.replace(/\u00a0/g, " "); -const MODEL_VARIANT_OPTIONS = [ - { value: "none", label: "None" }, - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - { value: "xhigh", label: "X-High" }, +const MODEL_VARIANT_OPTIONS = () => [ + { value: "none", label: t("session.variant_none") }, + { value: "low", label: t("session.variant_low") }, + { value: "medium", label: t("session.variant_medium") }, + { value: "high", label: t("session.variant_high") }, + { value: "xhigh", label: t("session.variant_xhigh") }, ]; const partsToText = (parts: ComposerPart[]) => @@ -254,7 +255,7 @@ const buildPartsFromEditor = (root: HTMLElement, pasteTextById?: Map { if (attachmentsDisabled()) { - props.onToast(props.attachmentsDisabledReason ?? "Attachments are unavailable."); + props.onToast(props.attachmentsDisabledReason ?? t("session.attachments_unavailable")); return; } const next: ComposerAttachment[] = []; for (const file of files) { if (!ACCEPTED_FILE_TYPES.includes(file.type)) { - props.onToast(`${file.name} is not a supported attachment type.`); + props.onToast(t("session.toast_unsupported_type")); continue; } if (file.size > MAX_ATTACHMENT_BYTES) { - props.onToast(`${file.name} exceeds the 8MB limit.`); + props.onToast(t("session.toast_file_too_large").replace("{name}", file.name)); continue; } try { @@ -923,7 +924,7 @@ export default function Composer(props: ComposerProps) { // Pre-check: data URL will be embedded in JSON body; reject if too large const estimatedJsonBytes = dataUrl.length + 512; // data URL + JSON overhead if (estimatedJsonBytes > MAX_ATTACHMENT_BYTES) { - props.onToast(`${file.name} is too large after encoding. Try a smaller image.`); + props.onToast(t("session.toast_image_too_large").replace("{name}", file.name)); continue; } next.push({ @@ -935,7 +936,7 @@ export default function Composer(props: ComposerProps) { dataUrl, }); } catch (error) { - props.onToast(error instanceof Error ? error.message : "Failed to read attachment"); + props.onToast(error instanceof Error ? error.message : t("session.toast_read_failed")); } } if (next.length) { @@ -971,7 +972,7 @@ export default function Composer(props: ComposerProps) { pasteCounter += 1; const id = `paste-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const label = `[pasted text ${pasteCounter}]`; + const label = `[${t("session.pasted_text").replace(/[[\]]/g, "")} ${pasteCounter}]`; const part = { type: "paste", id, label, text, lines } as const; const span = createPasteSpan(part); @@ -1035,7 +1036,7 @@ export default function Composer(props: ComposerProps) { event.preventDefault(); const hasSupported = allFiles.some((file) => ACCEPTED_FILE_TYPES.includes(file.type)); if (!hasSupported) { - props.onToast("Unsupported attachment type."); + props.onToast(t("session.toast_unsupported_type")); return; } void addAttachments(allFiles); @@ -1050,7 +1051,7 @@ export default function Composer(props: ComposerProps) { const hasAbsoluteWindows = /(^|\s)[a-zA-Z]:\\/.test(trimmedForCheck); if (hasFileUrl || hasAbsolutePosix || hasAbsoluteWindows) { props.onToast( - "This is a remote worker. Sandboxes are remote too. To share files with it, upload them to the Inbox in the sidebar.", + t("session.toast_remote_drop_hint") ); setShowInboxUploadAction(Boolean(props.onUploadInboxFiles)); } @@ -1328,7 +1329,7 @@ export default function Composer(props: ComposerProps) {
event.preventDefault()}> No matches found.
} + fallback={
{t("session.search_no_matches")}
} > {(option: MentionOption) => { @@ -1391,7 +1392,7 @@ export default function Composer(props: ComposerProps) { when={slashFiltered().length} fallback={
- {slashLoaded() ? "No commands found." : "Loading commands..."} + {slashLoaded() ? t("session.composer_no_commands") : t("session.composer_loading_commands")}
} > @@ -1438,8 +1439,8 @@ export default function Composer(props: ComposerProps) { class="w-full mb-2 flex items-center justify-between gap-3 rounded-xl border border-green-7/20 bg-green-7/10 px-3 py-2 text-left text-sm text-green-12 transition-colors hover:bg-green-7/15" onClick={props.onNotionBannerClick} > - Try it now: set up my CRM in Notion - Insert prompt + {t("session.try_notion_prompt")} + {t("session.insert_prompt")}
@@ -1459,7 +1460,7 @@ export default function Composer(props: ComposerProps) {
{attachment.name}
- {attachment.kind === "image" ? "Image" : attachment.mimeType || "File"} + {attachment.kind === "image" ? t("session.attachment_image") : attachment.mimeType || t("session.attachment_file")}
@@ -1501,13 +1502,13 @@ export default function Composer(props: ComposerProps) {
-
Remote workspace
+
{t("session.composer_remote_workspace")}
- Ask OpenWork... + {t("session.placeholder")}
@@ -1581,7 +1582,7 @@ export default function Composer(props: ComposerProps) { onClick={props.onToggleAgentPicker} disabled={props.busy} aria-expanded={props.agentPickerOpen} - title="Agent" + title={t("session.agent_label")} > {props.agentLabel} @@ -1591,14 +1592,14 @@ export default function Composer(props: ComposerProps) {
- Agent + {t("session.agent_label")}
event.preventDefault()}> Loading agents...
+
{t("session.loading_agents")}
} > @@ -1613,7 +1614,7 @@ export default function Composer(props: ComposerProps) { props.onSelectAgent(null); }} > - Default agent + {t("session.default_agent")} @@ -1672,17 +1673,17 @@ export default function Composer(props: ComposerProps) { disabled={props.busy} aria-expanded={variantMenuOpen()} > - Thinking + {t("settings.thinking")} {props.modelVariantLabel}
- Thinking effort + {t("session.thinking_effort")}
- + {(option) => ( )} @@ -1719,7 +1720,7 @@ export default function Composer(props: ComposerProps) { ? "bg-dls-active text-dls-secondary" : "bg-dls-accent text-white" }`} - title="Send" + title={t("session.send_button")} > @@ -1729,7 +1730,7 @@ export default function Composer(props: ComposerProps) { type="button" onClick={() => props.onStop()} class="p-1.5 rounded-full bg-gray-12 text-gray-1 hover:bg-gray-11 transition-colors" - title="Stop" + title={t("session.stop_button")} > diff --git a/packages/app/src/app/components/session/context-panel.tsx b/packages/app/src/app/components/session/context-panel.tsx index e89ffe40a..6b4048f6c 100644 --- a/packages/app/src/app/components/session/context-panel.tsx +++ b/packages/app/src/app/components/session/context-panel.tsx @@ -1,4 +1,5 @@ import { For, Show } from "solid-js"; +import { t } from "../../../i18n"; import { ChevronDown, Circle, File, Folder, Package } from "lucide-solid"; import { SUGGESTED_PLUGINS } from "../../constants"; @@ -108,19 +109,19 @@ const getSmartFileName = (files: string[], file: string): string => { }; const mcpStatusLabel = (status?: McpStatus, disabled?: boolean) => { - if (disabled) return "Disabled"; - if (!status) return "Disconnected"; + if (disabled) return t("session.mcp_status_disabled"); + if (!status) return t("session.mcp_status_disconnected"); switch (status.status) { case "connected": - return "Connected"; + return t("session.mcp_status_connected"); case "needs_auth": - return "Needs auth"; + return t("session.mcp_status_needs_auth"); case "needs_client_registration": - return "Register client"; + return t("session.mcp_status_register"); case "failed": - return "Failed"; + return t("session.mcp_status_failed"); default: - return "Disconnected"; + return t("session.mcp_status_disconnected"); } }; @@ -152,7 +153,7 @@ export default function ContextPanel(props: ContextPanelProps) { class="w-full px-4 py-3 flex items-center justify-between text-sm text-gray-12 font-medium" onClick={() => props.onToggleSection("context")} > - Context + {t("session.context_title")}
- Working files + {t("session.working_files")}
None yet.
} + fallback={
{t("session.working_files_empty")}
} > {(file) => { @@ -204,7 +205,7 @@ export default function ContextPanel(props: ContextPanelProps) { class="w-full px-4 py-3 flex items-center justify-between text-sm text-gray-12 font-medium" onClick={() => props.onToggleSection("plugins")} > - Plugins + {t("session.plugins_title")} - {props.activePluginStatus ?? "No plugins loaded."} + {props.activePluginStatus ?? t("session.plugins_empty")}
} > @@ -254,7 +255,7 @@ export default function ContextPanel(props: ContextPanelProps) { class="w-full px-4 py-3 flex items-center justify-between text-sm text-gray-12 font-medium" onClick={() => props.onToggleSection("mcp")} > - MCP + {t("session.mcp_title")} - {props.mcpStatus ?? "No MCP servers loaded."} + {props.mcpStatus ?? t("session.mcp_empty")}
} > @@ -304,7 +305,7 @@ export default function ContextPanel(props: ContextPanelProps) { class="w-full px-4 py-3 flex items-center justify-between text-sm text-gray-12 font-medium" onClick={() => props.onToggleSection("skills")} > - Skills + {t("session.skills_title")} - {props.skillsStatus ?? "No skills loaded."} + {props.skillsStatus ?? t("session.skills_empty")}
} > @@ -353,7 +354,7 @@ export default function ContextPanel(props: ContextPanelProps) { class="w-full px-4 py-3 flex items-center justify-between text-sm text-gray-12 font-medium" onClick={() => props.onToggleSection("authorizedFolders")} > - Authorized folders + {t("session.authorized_folders_title")} None yet.
} + fallback={
{t("session.none_yet")}
} > {(folder) => ( diff --git a/packages/app/src/app/components/session/inbox-panel.tsx b/packages/app/src/app/components/session/inbox-panel.tsx index 609f00d63..856e39a94 100644 --- a/packages/app/src/app/components/session/inbox-panel.tsx +++ b/packages/app/src/app/components/session/inbox-panel.tsx @@ -1,4 +1,5 @@ import { For, Show, createEffect, createMemo, createSignal } from "solid-js"; +import { t } from "../../../i18n"; import { Download, Inbox, RefreshCw, UploadCloud } from "lucide-solid"; import type { OpenworkInboxItem, OpenworkServerClient } from "../../lib/openwork-server"; @@ -45,7 +46,7 @@ export default function InboxPanel(props: InboxPanelProps) { }); const connected = createMemo(() => Boolean(props.client && (props.workspaceId ?? "").trim())); - const helperText = "Share files with your remote worker."; + const helperText = () => t("session.inbox_helper_text"); const visibleItems = createMemo(() => (items() ?? []).slice(0, maxPreview())); const hiddenCount = createMemo(() => Math.max(0, (items() ?? []).length - visibleItems().length)); @@ -68,7 +69,7 @@ export default function InboxPanel(props: InboxPanelProps) { const result = await client.listInbox(workspaceId); setItems(result.items ?? []); } catch (err) { - const message = err instanceof Error ? err.message : "Failed to load inbox"; + const message = err instanceof Error ? err.message : t("session.inbox_toast_load_failed"); setError(message); setItems([]); } finally { @@ -80,7 +81,7 @@ export default function InboxPanel(props: InboxPanelProps) { const client = props.client; const workspaceId = (props.workspaceId ?? "").trim(); if (!client || !workspaceId) { - toast("Connect to a worker to upload inbox files."); + toast(t("session.inbox_toast_connect_upload")); return; } if (!files.length) return; @@ -89,14 +90,14 @@ export default function InboxPanel(props: InboxPanelProps) { setError(null); try { const label = files.length === 1 ? files[0]?.name ?? "file" : `${files.length} files`; - toast(`Uploading ${label}...`); + toast(t("session.inbox_toast_uploading").replace("{label}", label)); for (const file of files) { await client.uploadInbox(workspaceId, file); } - toast("Uploaded to worker inbox."); + toast(t("session.inbox_toast_uploaded")); await refresh(); } catch (err) { - const message = err instanceof Error ? err.message : "Inbox upload failed"; + const message = err instanceof Error ? err.message : t("session.inbox_toast_upload_failed"); setError(message); toast(message); } finally { @@ -108,9 +109,9 @@ export default function InboxPanel(props: InboxPanelProps) { const path = toInboxWorkspacePath(item); try { await navigator.clipboard.writeText(path); - toast(`Copied: ${path}`); + toast(t("session.inbox_toast_copied").replace("{path}", path)); } catch { - toast("Copy failed. Your browser may block clipboard access."); + toast(t("session.inbox_toast_copy_failed")); } }; @@ -118,12 +119,12 @@ export default function InboxPanel(props: InboxPanelProps) { const client = props.client; const workspaceId = (props.workspaceId ?? "").trim(); if (!client || !workspaceId) { - toast("Connect to a worker to download inbox files."); + toast(t("session.inbox_toast_connect_download")); return; } const id = String(item.id ?? "").trim(); if (!id) { - toast("Missing inbox item id."); + toast(t("session.inbox_toast_missing_id")); return; } @@ -139,7 +140,7 @@ export default function InboxPanel(props: InboxPanelProps) { a.remove(); URL.revokeObjectURL(url); } catch (err) { - const message = err instanceof Error ? err.message : "Download failed"; + const message = err instanceof Error ? err.message : t("session.inbox_toast_download_failed"); toast(message); } }; @@ -157,7 +158,7 @@ export default function InboxPanel(props: InboxPanelProps) {
-
Inbox
+
{t("session.inbox_title")}
@@ -168,8 +169,8 @@ export default function InboxPanel(props: InboxPanelProps) { type="button" class="rounded-md p-1 text-dls-secondary hover:text-dls-text hover:bg-dls-active transition-colors" onClick={() => void refresh()} - title="Refresh inbox" - aria-label="Refresh inbox" + title={t("session.inbox_refresh_tooltip")} + aria-label={t("session.inbox_refresh_tooltip")} disabled={!connected() || loading()} > @@ -212,15 +213,15 @@ export default function InboxPanel(props: InboxPanelProps) { if (files.length) void uploadFiles(files); }} disabled={uploading()} - title={connected() ? "Drop files here to upload" : "Connect to a worker to upload"} + title={connected() ? t("session.inbox_drop_hint") : t("session.inbox_upload_disabled")} >
- {uploading() ? "Uploading..." : "Drop files or click to upload"} + {uploading() ? t("session.inbox_uploading") : t("session.inbox_upload_cta")}
-
{helperText}
+
{helperText()}
@@ -235,8 +236,8 @@ export default function InboxPanel(props: InboxPanelProps) { when={visibleItems().length > 0} fallback={
- - No inbox files yet. + + {t("session.inbox_empty")}
} @@ -254,8 +255,8 @@ export default function InboxPanel(props: InboxPanelProps) { type="button" class="min-w-0 flex-1 text-left" onClick={() => void copyPath(item)} - title={rel() ? `Copy ${INBOX_PREFIX}${rel()}` : "Copy inbox path"} - aria-label={rel() ? `Copy ${INBOX_PREFIX}${rel()}` : "Copy inbox path"} + title={rel() ? `Copy ${INBOX_PREFIX}${rel()}` : t("session.inbox_copy_tooltip")} + aria-label={rel() ? `Copy ${INBOX_PREFIX}${rel()}` : t("session.inbox_copy_tooltip")} disabled={!connected()} >
{name()}
@@ -276,8 +277,8 @@ export default function InboxPanel(props: InboxPanelProps) { type="button" class="shrink-0 rounded-md p-1 text-dls-secondary hover:text-dls-text hover:bg-dls-hover" onClick={() => void downloadItem(item)} - title="Download" - aria-label="Download" + title={t("session.inbox_download_tooltip")} + aria-label={t("session.inbox_download_tooltip")} disabled={!connected()} > @@ -289,7 +290,7 @@ export default function InboxPanel(props: InboxPanelProps) { 0}> -
Showing first {maxPreview()}.
+
{t("session.inbox_showing_first").replace("{count}", String(maxPreview()))}
diff --git a/packages/app/src/app/components/session/message-list.tsx b/packages/app/src/app/components/session/message-list.tsx index 87e33f39c..5457ec4ae 100644 --- a/packages/app/src/app/components/session/message-list.tsx +++ b/packages/app/src/app/components/session/message-list.tsx @@ -1,8 +1,9 @@ -import { For, Show, createMemo, createSignal, onCleanup } from "solid-js"; +import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"; import type { JSX } from "solid-js"; import type { Part } from "@opencode-ai/sdk/v2/client"; import { Check, ChevronDown, ChevronRight, Copy, Eye, File, FileEdit, FolderSearch, Pencil, Search, Sparkles, Terminal } from "lucide-solid"; +import { t } from "../../../i18n"; import type { MessageGroup, MessageWithParts } from "../../types"; import { classifyTool, groupMessageParts, summarizeStep } from "../../utils"; import PartView from "../part-view"; @@ -281,7 +282,7 @@ export default function MessageList(props: MessageListProps) { {/* Skill badge */} - skill + {t("session.skill_badge")} {/* Detail - truncated to single line */} @@ -354,12 +355,12 @@ export default function MessageList(props: MessageListProps) { class={`transition-transform duration-200 ${expanded() ? "rotate-90" : ""}`} /> - {expanded() ? "Hide steps" : `Show ${totalSteps()} step${totalSteps() === 1 ? "" : "s"}`} + {expanded() ? t("session.hide_steps") : t("session.show_steps").replace("{count}", String(totalSteps())).replace("{plural}", totalSteps() === 1 ? "" : "s")} - running + {t("session.step_running")} @@ -512,7 +513,7 @@ export default function MessageList(props: MessageListProps) {
-
Workspaces
+
{t("dashboard.workspaces")}
0} fallback={
- No workspaces in this session yet. Add one to get started. + {t("sidebar.no_workspaces")}
} > @@ -365,7 +366,7 @@ export default function SessionSidebar(props: SidebarProps) { - {isSandboxWorkspace() ? "Sandbox" : "Remote"} + {isSandboxWorkspace() ? t("dashboard.workspace_sandbox") : t("dashboard.workspace_remote")}
@@ -382,11 +383,11 @@ export default function SessionSidebar(props: SidebarProps) { - Needs attention + {t("sidebar.needs_attention")} - Switch}> - Active + {t("settings.switch_mode")}}> + {t("session.active_variant")} @@ -398,7 +399,7 @@ export default function SessionSidebar(props: SidebarProps) { type="button" class="p-1 rounded-md text-gray-9 hover:text-gray-12 hover:bg-gray-2" onClick={() => toggleWorkspaceCollapse(group.workspace.id)} - title={collapsed() ? "Expand" : "Collapse"} + title={collapsed() ? t("sidebar.expand") : t("sidebar.collapse")} > handleDragStart(event, group.workspace.id)} onDragEnd={handleDragEnd} @@ -433,7 +434,7 @@ export default function SessionSidebar(props: SidebarProps) { disabled={isConnecting()} > - Edit connection + {t("dashboard.menu_edit_connection")} @@ -453,7 +454,7 @@ export default function SessionSidebar(props: SidebarProps) { disabled={isConnecting()} > - Stop sandbox + {t("dashboard.menu_stop_sandbox")}
0} fallback={
- No sessions yet. + {t("dashboard.no_sessions")}
} > @@ -525,11 +526,11 @@ export default function SessionSidebar(props: SidebarProps) { type="button" class="w-full px-3 py-2 rounded-lg text-xs text-gray-9 hover:text-gray-12 hover:bg-gray-2 transition-colors" onClick={() => toggleShowAllSessions(group.workspace.id)} - > - {showingAll() - ? "Show fewer" - : `Show ${sessions().length - MAX_SESSIONS_PREVIEW} more`} - + > + {showingAll() + ? t("session.artifacts_show_fewer") + : t("dashboard.show_more_count").replace("{count}", String(sessions().length - MAX_SESSIONS_PREVIEW))} +
@@ -549,7 +550,7 @@ export default function SessionSidebar(props: SidebarProps) { onDrop={(event) => handleDrop(event, null)} > - Add new workspace + {t("sidebar.add_new_workspace")}
@@ -562,7 +563,7 @@ export default function SessionSidebar(props: SidebarProps) { }} > - New worker + {t("dashboard.new_worker")}
@@ -600,7 +601,7 @@ export default function SessionSidebar(props: SidebarProps) { class="w-full px-4 py-3 flex items-center justify-between text-sm text-gray-12 font-medium" onClick={() => props.onToggleSection("progress")} > - Progress + {t("session.progress")} - New task + {t("session.new_task")}
diff --git a/packages/app/src/app/components/status-bar.tsx b/packages/app/src/app/components/status-bar.tsx index a89389170..1c13283af 100644 --- a/packages/app/src/app/components/status-bar.tsx +++ b/packages/app/src/app/components/status-bar.tsx @@ -1,5 +1,6 @@ import { Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"; import { Cpu, MessageCircle, Server, Settings } from "lucide-solid"; +import { t } from "../../i18n"; import type { OpenworkServerStatus } from "../lib/openwork-server"; import type { OpenCodeRouterStatus } from "../lib/tauri"; @@ -27,35 +28,35 @@ export default function StatusBar(props: StatusBarProps) { const opencodeStatusMeta = createMemo(() => ({ dot: props.clientConnected ? "bg-green-9" : "bg-gray-6", text: props.clientConnected ? "text-green-11" : "text-gray-10", - label: props.clientConnected ? "Connected" : "Not connected", + label: props.clientConnected ? t("status_bar.connected") : t("status_bar.not_connected"), })); const openworkStatusMeta = createMemo(() => { switch (props.openworkServerStatus) { case "connected": - return { dot: "bg-green-9", text: "text-green-11", label: "Ready" }; + return { dot: "bg-green-9", text: "text-green-11", label: t("status_bar.ready") }; case "limited": - return { dot: "bg-amber-9", text: "text-amber-11", label: "Limited access" }; + return { dot: "bg-amber-9", text: "text-amber-11", label: t("status_bar.limited_access") }; default: - return { dot: "bg-gray-6", text: "text-gray-10", label: "Unavailable" }; + return { dot: "bg-gray-6", text: "text-gray-10", label: t("status_bar.unavailable") }; } }); const messagingMeta = createMemo(() => { const status = opencodeRouterStatus(); if (!status) { - return { dot: "bg-gray-6", text: "text-gray-10", label: "Messaging bridge unavailable" }; + return { dot: "bg-gray-6", text: "text-gray-10", label: t("status_bar.messaging_unavailable") }; } const telegramConfigured = (status.telegram.items?.length ?? 0) > 0; const slackConfigured = (status.slack.items?.length ?? 0) > 0; const configuredCount = [telegramConfigured, slackConfigured].filter(Boolean).length; if (status.running && configuredCount > 0) { - return { dot: "bg-green-9", text: "text-green-11", label: "Messaging bridge ready" }; + return { dot: "bg-green-9", text: "text-green-11", label: t("status_bar.messaging_ready") }; } if (configuredCount > 0 || status.running) { - return { dot: "bg-amber-9", text: "text-amber-11", label: "Messaging bridge setup" }; + return { dot: "bg-amber-9", text: "text-amber-11", label: t("status_bar.messaging_setup") }; } - return { dot: "bg-gray-6", text: "text-gray-10", label: "Messaging bridge offline" }; + return { dot: "bg-gray-6", text: "text-gray-10", label: t("status_bar.messaging_offline") }; }); type ProTip = { @@ -79,7 +80,7 @@ export default function StatusBar(props: StatusBarProps) { const proTips = createMemo(() => [ { id: "slack", - label: "Connect Slack", + label: t("status_bar.tip_connect_slack"), enabled: () => { const status = opencodeRouterStatus(); return Boolean(status && (status.slack.items?.length ?? 0) === 0); @@ -88,7 +89,7 @@ export default function StatusBar(props: StatusBarProps) { }, { id: "telegram", - label: "Connect Telegram", + label: t("status_bar.tip_connect_telegram"), enabled: () => { const status = opencodeRouterStatus(); return Boolean(status && (status.telegram.items?.length ?? 0) === 0); @@ -97,13 +98,13 @@ export default function StatusBar(props: StatusBarProps) { }, { id: "notion", - label: "Connect Notion MCP", + label: t("status_bar.tip_connect_notion"), enabled: () => notionStatus() !== "connected", action: () => runAction(props.onOpenMcp), }, { id: "providers", - label: "Use your own models (OpenRouter, Anthropic, OpenAI)", + label: t("status_bar.tip_providers"), enabled: () => props.clientConnected && providerConnectedCount() === 0, action: () => runAction(props.onOpenProviders), }, @@ -192,7 +193,7 @@ export default function StatusBar(props: StatusBarProps) {
@@ -204,7 +205,7 @@ export default function StatusBar(props: StatusBarProps) {
@@ -222,7 +223,7 @@ export default function StatusBar(props: StatusBarProps) { title={activeTip()?.label} aria-label={activeTip()?.label} > - Tip + {t("status_bar.tip_label")} {activeTip()?.label} @@ -230,11 +231,11 @@ export default function StatusBar(props: StatusBarProps) { variant="ghost" class="h-7 px-2.5 py-0 text-xs" onClick={props.onOpenSettings} - title="Settings" + title={t("status_bar.settings")} > - Settings + {t("status_bar.settings")}
diff --git a/packages/app/src/app/pages/config.tsx b/packages/app/src/app/pages/config.tsx index 8fc64d034..035df8e82 100644 --- a/packages/app/src/app/pages/config.tsx +++ b/packages/app/src/app/pages/config.tsx @@ -1,6 +1,7 @@ import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; import { isTauriRuntime } from "../utils"; +import { t } from "../../i18n"; import Button from "../components/button"; import TextInput from "../components/text-input"; @@ -66,11 +67,12 @@ export default function ConfigView(props: ConfigViewProps) { const openworkStatusLabel = createMemo(() => { switch (props.openworkServerStatus) { case "connected": - return "Connected"; + return t("status.connected"); case "limited": - return "Limited"; + return t("status.limited"); + case "disconnected": default: - return "Not connected"; + return t("status.disconnected"); } }); @@ -86,14 +88,14 @@ export default function ConfigView(props: ConfigViewProps) { }); const reloadAvailabilityReason = createMemo(() => { - if (!props.clientConnected) return "Connect to this worker to reload."; + if (!props.clientConnected) return t("config.reload_connect_hint"); if (!props.canReloadWorkspace) { - return "Reloading is only available for local workers or connected OpenWork servers."; + return t("config.reload_local_only"); } return null; }); - const reloadButtonLabel = createMemo(() => (props.reloadBusy ? "Reloading..." : "Reload engine")); + const reloadButtonLabel = createMemo(() => (props.reloadBusy ? t("config.reloading") : t("config.reload_engine"))); const reloadButtonTone = createMemo(() => (props.anyActiveRuns ? "danger" : "secondary")); const reloadButtonDisabled = createMemo(() => props.reloadBusy || Boolean(reloadAvailabilityReason())); @@ -123,8 +125,8 @@ export default function ConfigView(props: ConfigViewProps) { const hostInfo = createMemo(() => props.openworkServerHostInfo); const hostStatusLabel = createMemo(() => { - if (!hostInfo()?.running) return "Offline"; - return "Available"; + if (!hostInfo()?.running) return t("config.status_offline"); + return t("config.status_available"); }); const hostStatusStyle = createMemo(() => { if (!hostInfo()?.running) return "bg-gray-4/60 text-gray-11 border-gray-7/50"; @@ -209,29 +211,29 @@ export default function ConfigView(props: ConfigViewProps) { return (
-
Workspace config
+
{t("config.title")}
- These settings affect the active workspace (sharing, reload, bots). Global app behavior lives in Settings. + {t("config.subtitle")}
- Workspace: {props.openworkServerWorkspaceId} + {t("config.workspace_label")}{props.openworkServerWorkspaceId}
-
Engine reload
-
Restart the OpenCode server for this workspace.
+
{t("config.engine_reload_title")}
+
{t("config.engine_reload_subtitle")}
-
Reload now
-
Applies config updates and reconnects your session.
+
{t("config.reload_now")}
+
{t("config.reload_now_subtitle")}
-
Reloading will stop active tasks.
+
{t("config.reload_warning")}
{props.reloadError}
@@ -253,10 +255,10 @@ export default function ConfigView(props: ConfigViewProps) {
-
Auto reload (local)
-
Reload automatically after agents/skills/commands/config change (only when idle).
+
{t("config.auto_reload_title")}
+
{t("config.auto_reload_subtitle")}
-
Available for local workspaces in the desktop app.
+
{t("config.auto_reload_desktop_only")}
-
Resume sessions after auto reload
+
{t("config.resume_sessions_title")}
- If a reload was queued while tasks were running, send a resume message afterward. + {t("config.resume_sessions_subtitle")}
@@ -296,8 +298,8 @@ export default function ConfigView(props: ConfigViewProps) {
-
Diagnostics bundle
-
Copy sanitized runtime state for debugging.
+
{t("config.diagnostics_title")}
+
{t("config.diagnostics_subtitle")}
@@ -318,9 +320,9 @@ export default function ConfigView(props: ConfigViewProps) {
         
-
OpenWork server sharing
+
{t("config.sharing_title")}
- Share these details with a trusted device. Keep the server on the same network for the fastest setup. + {t("config.sharing_subtitle")}
@@ -331,13 +333,13 @@ export default function ConfigView(props: ConfigViewProps) {
-
OpenWork Server URL
-
{hostConnectUrl() || "Starting server…"}
+
{t("config.server_url_label")}
+
{hostConnectUrl() || t("config.starting_server")}
{hostConnectUrlUsesMdns() - ? ".local names are easier to remember but may not resolve on all networks." - : "Use your local IP on the same Wi-Fi for the fastest connection."} + ? t("config.mdns_hint") + : t("config.ip_hint")}
@@ -347,13 +349,13 @@ export default function ConfigView(props: ConfigViewProps) { onClick={() => handleCopy(hostConnectUrl(), "host-url")} disabled={!hostConnectUrl()} > - {copyingField() === "host-url" ? "Copied" : "Copy"} + {copyingField() === "host-url" ? t("config.copied") : t("config.copy")}
-
Access token
+
{t("config.access_token_label")}
{clientTokenVisible() ? hostInfo()?.clientToken || "—" @@ -361,7 +363,7 @@ export default function ConfigView(props: ConfigViewProps) { ? "••••••••••••" : "—"}
-
Use on phones or laptops connecting to this server.
+
{t("config.access_token_hint")}
-
Server token
+
{t("config.server_token_label")}
{hostTokenVisible() ? hostInfo()?.hostToken || "—" @@ -393,7 +395,7 @@ export default function ConfigView(props: ConfigViewProps) { ? "••••••••••••" : "—"}
-
Keep private. Required for approval actions.
+
{t("config.server_token_hint")}
-
- For per-workspace sharing links, use Share... in the workspace menu. -
+
-
OpenWork server
+
{t("config.openwork_server_title")}
- Connect to an OpenWork server. Use the URL and access token from your server admin. + {t("config.openwork_server_subtitle")}
{openworkStatusLabel()}
@@ -435,22 +435,22 @@ export default function ConfigView(props: ConfigViewProps) {
setOpenworkUrl(event.currentTarget.value)} - placeholder="http://127.0.0.1:8787" - hint="Use the URL shared by your OpenWork server." + placeholder={t("dashboard.remote_base_url_placeholder")} + hint={t("dashboard.openwork_host_hint")} disabled={props.busy} />
-
Resolved worker URL: {resolvedWorkspaceUrl() || "Not set"}
-
Worker ID: {resolvedWorkspaceId() || "Unavailable"}
+
{t("config.resolved_worker_url")}{resolvedWorkspaceUrl() || t("config.not_set")}
+
{t("config.worker_id")}{resolvedWorkspaceId() || t("config.unavailable")}
@@ -485,27 +485,27 @@ export default function ConfigView(props: ConfigViewProps) { const ok = await props.testOpenworkServerConnection(next); setOpenworkTestState(ok ? "success" : "error"); setOpenworkTestMessage( - ok ? "Connection successful." : "Connection failed. Check the host URL and token.", + ok ? t("config.connection_success") : t("config.connection_failed") ); } catch (error) { - const message = error instanceof Error ? error.message : "Connection failed."; + const message = error instanceof Error ? error.message : t("config.connection_failed_generic"); setOpenworkTestState("error"); setOpenworkTestMessage(message); } }} disabled={props.busy || openworkTestState() === "testing"} > - {openworkTestState() === "testing" ? "Testing..." : "Test connection"} + {openworkTestState() === "testing" ? t("config.testing") : t("config.test_connection")}
@@ -521,25 +521,23 @@ export default function ConfigView(props: ConfigViewProps) { role="status" aria-live="polite" > - {openworkTestState() === "testing" ? "Testing connection..." : openworkTestMessage() ?? "Connection status updated."} + {openworkTestState() === "testing" ? t("config.testing_connection") : openworkTestMessage() ?? t("config.connection_status_updated")}
- -
OpenWork server connection needed to sync skills, plugins, and commands.
+ +
{t("config.sync_hint")}
-
Messaging identities
-
- Manage Telegram/Slack identities and routing in the Identities tab. -
+
{t("config.identities_title")}
+
- Some config features (local server sharing + messaging bridge) require the desktop app. + {t("config.desktop_only_hint")}
diff --git a/packages/app/src/app/pages/dashboard.tsx b/packages/app/src/app/pages/dashboard.tsx index 9bd58ded5..d3fa75a2a 100644 --- a/packages/app/src/app/pages/dashboard.tsx +++ b/packages/app/src/app/pages/dashboard.tsx @@ -14,6 +14,7 @@ import type { WorkspaceSessionGroup, View, } from "../types"; +import { t } from "../../i18n"; import type { McpDirectoryInfo } from "../constants"; import { formatRelativeTime, isTauriRuntime, normalizeDirectoryPath } from "../utils"; import { buildOpenworkWorkspaceBaseUrl, createOpenworkServerClient } from "../lib/openwork-server"; @@ -265,21 +266,21 @@ export default function DashboardView(props: DashboardViewProps) { const title = createMemo(() => { switch (props.tab) { case "scheduled": - return "Automations"; + return t("dashboard.tab_automations"); case "skills": - return "Skills"; + return t("dashboard.tab_skills"); case "plugins": - return "Extensions"; + return t("dashboard.tab_extensions"); case "mcp": - return "Extensions"; + return t("dashboard.tab_extensions"); case "identities": - return "Identities"; + return t("dashboard.tab_identities"); case "config": - return "Advanced"; + return t("dashboard.tab_advanced"); case "settings": - return "Settings"; + return t("dashboard.tab_settings"); default: - return "Automations"; + return t("dashboard.tab_automations"); } }); @@ -288,15 +289,15 @@ export default function DashboardView(props: DashboardViewProps) { workspace.openworkWorkspaceName?.trim() || workspace.name?.trim() || workspace.path?.trim() || - "Worker"; + t("dashboard.workspace_worker"); const workspaceKindLabel = (workspace: WorkspaceInfo) => workspace.workspaceType === "remote" ? workspace.sandboxBackend === "docker" || Boolean(workspace.sandboxRunId?.trim()) || Boolean(workspace.sandboxContainerName?.trim()) - ? "Sandbox" - : "Remote" - : "Local"; + ? t("dashboard.workspace_sandbox") + : t("dashboard.workspace_remote") + : t("dashboard.workspace_local"); const openSessionFromList = (workspaceId: string, sessionId: string) => { // For same-workspace clicks, just select the session without workspace activation @@ -390,7 +391,7 @@ export default function DashboardView(props: DashboardViewProps) { const showMoreLabel = (workspaceId: string, total: number) => { const remaining = Math.max(0, total - previewCount(workspaceId)); const nextCount = Math.min(MAX_SESSIONS_PREVIEW, remaining); - return nextCount > 0 ? `Show ${nextCount} more` : "Show more"; + return nextCount > 0 ? t("dashboard.show_more_count").replace("{count}", nextCount.toString()) : t("dashboard.show_more"); }; const [workspaceMenuId, setWorkspaceMenuId] = createSignal(null); let workspaceMenuRef: HTMLDivElement | undefined; @@ -605,23 +606,23 @@ export default function DashboardView(props: DashboardViewProps) { const token = props.openworkServerHostInfo?.clientToken?.trim() || ""; return [ { - label: "OpenWork worker URL", + label: t("dashboard.share_worker_url"), value: url, - placeholder: !isTauriRuntime() ? "Desktop app required" : "Starting server...", + placeholder: !isTauriRuntime() ? t("dashboard.share_desktop_required") : t("dashboard.share_starting_server"), hint: mountedUrl - ? "Use on phones or laptops connecting to this worker." + ? t("dashboard.share_worker_hint") : hostUrl - ? "Worker URL is resolving; host URL shown as fallback." + ? t("dashboard.share_resolving_hint") : undefined, }, { - label: "Access token", + label: t("config.access_token_label"), value: token, secret: true, - placeholder: isTauriRuntime() ? "-" : "Desktop app required", + placeholder: isTauriRuntime() ? "-" : t("dashboard.share_desktop_required"), hint: mountedUrl - ? "Use on phones or laptops connecting to this worker." - : "Use on phones or laptops connecting to this host.", + ? t("dashboard.share_worker_hint") + : t("dashboard.share_host_hint"), }, ]; } @@ -635,15 +636,15 @@ export default function DashboardView(props: DashboardViewProps) { ""; return [ { - label: "OpenWork worker URL", + label: t("dashboard.share_worker_url"), value: url, }, { - label: "Access token", + label: t("config.access_token_label"), value: token, secret: true, - placeholder: token ? undefined : "Set token in Advanced", - hint: "This token grants access to the worker on that host.", + placeholder: token ? undefined : t("dashboard.share_token_advanced_hint"), + hint: t("dashboard.share_token_grant_hint"), }, ]; } @@ -652,13 +653,13 @@ export default function DashboardView(props: DashboardViewProps) { const directory = ws.directory?.trim() || ""; return [ { - label: "OpenCode base URL", + label: t("dashboard.share_opencode_url"), value: baseUrl, }, { - label: "Directory", + label: t("dashboard.share_directory"), value: directory, - placeholder: "(auto)", + placeholder: t("dashboard.share_auto"), }, ]; }); @@ -667,17 +668,17 @@ export default function DashboardView(props: DashboardViewProps) { const ws = shareWorkspace(); if (!ws) return null; if (ws.workspaceType === "local" && props.engineInfo?.runtime === "direct") { - return "Engine runtime is set to Direct. Switching local workers can restart the host and disconnect clients. The token may change after a restart."; + return t("dashboard.share_direct_warning"); } return null; }); const exportDisabledReason = createMemo(() => { const ws = shareWorkspace(); - if (!ws) return "Export is available for local workers in the desktop app."; - if (ws.workspaceType === "remote") return "Export is only supported for local workers."; - if (!isTauriRuntime()) return "Export is available in the desktop app."; - if (props.exportWorkspaceBusy) return "Export is already running."; + if (!ws) return t("dashboard.export_local_desktop"); + if (ws.workspaceType === "remote") return t("dashboard.export_local_only"); + if (!isTauriRuntime()) return t("dashboard.export_desktop_only"); + if (props.exportWorkspaceBusy) return t("dashboard.export_running"); return null; }); @@ -698,13 +699,13 @@ export default function DashboardView(props: DashboardViewProps) { const updatePillLabel = createMemo(() => { const state = props.updateStatus?.state; if (state === "ready") { - return props.anyActiveRuns ? "Update ready" : "Install update"; + return props.anyActiveRuns ? t("dashboard.update_ready_btn") : t("dashboard.install_update_btn"); } if (state === "downloading") { const percent = updateDownloadPercent(); - return percent == null ? "Downloading" : `Downloading ${percent}%`; + return percent == null ? t("dashboard.downloading_btn") : t("dashboard.downloading_percent_btn").replace("{percent}", percent.toString()); } - return "Update available"; + return t("dashboard.update_available_btn"); }); const updatePillTone = createMemo(() => { @@ -720,11 +721,11 @@ export default function DashboardView(props: DashboardViewProps) { const state = props.updateStatus?.state; if (state === "ready") { return props.anyActiveRuns - ? `Update ready ${version}. Stop active runs to restart.` - : `Restart to apply update ${version}`; + ? t("dashboard.update_ready_tooltip").replace("{version}", version) + : t("dashboard.update_restart_tooltip").replace("{version}", version); } - if (state === "downloading") return `Downloading update ${version}`; - return `Update available ${version}`; + if (state === "downloading") return t("dashboard.update_downloading_tooltip").replace("{version}", version); + return t("dashboard.update_available_tooltip").replace("{version}", version); }); const handleUpdatePillClick = () => { @@ -765,7 +766,7 @@ export default function DashboardView(props: DashboardViewProps) {
- Tasks + {t("dashboard.tasks_header")}
@@ -822,9 +823,9 @@ export default function DashboardView(props: DashboardViewProps) { - Error + {t("dashboard.tasks_error_badge")} {/* Session count intentionally hidden (not a useful signal and it can crowd the header actions). */} @@ -873,7 +874,7 @@ export default function DashboardView(props: DashboardViewProps) { setWorkspaceMenuId(null); }} > - Edit name + {t("dashboard.menu_edit_name")} @@ -918,7 +919,7 @@ export default function DashboardView(props: DashboardViewProps) { setWorkspaceMenuId(null); }} > - Stop sandbox + {t("dashboard.menu_stop_sandbox")}
@@ -982,9 +983,9 @@ export default function DashboardView(props: DashboardViewProps) {
- Failed to load tasks + {t("dashboard.tasks_error")}
} @@ -1027,8 +1028,8 @@ export default function DashboardView(props: DashboardViewProps) { onClick={() => createTaskInWorkspace(workspace().id)} disabled={props.newTaskDisabled} > - No tasks yet. - + {t("dashboard.tasks_empty")} + @@ -1045,7 +1046,7 @@ export default function DashboardView(props: DashboardViewProps) { } >
- Loading tasks... + {t("dashboard.loading_tasks")}
@@ -1063,7 +1064,7 @@ export default function DashboardView(props: DashboardViewProps) { onClick={() => setAddWorkspaceMenuOpen((prev) => !prev)} > - Add a worker + {t("dashboard.add_worker")}
@@ -1076,7 +1077,7 @@ export default function DashboardView(props: DashboardViewProps) { }} > - New worker + {t("dashboard.new_worker")}
@@ -1370,7 +1371,7 @@ export default function DashboardView(props: DashboardViewProps) { onClick={props.repairOpencodeCache} disabled={props.cacheRepairBusy || !props.developerMode} > - {props.cacheRepairBusy ? "Repairing cache" : "Repair cache"} + {props.cacheRepairBusy ? t("dashboard.repairing_cache_btn") : t("dashboard.repair_cache_btn")} @@ -1445,7 +1446,7 @@ export default function DashboardView(props: DashboardViewProps) { onClick={() => props.setTab("scheduled")} > - Automations + {t("dashboard.tab_automations")}
@@ -1489,11 +1490,11 @@ export default function DashboardView(props: DashboardViewProps) {
diff --git a/packages/app/src/app/pages/extensions.tsx b/packages/app/src/app/pages/extensions.tsx index 5fb458d8a..0e78de113 100644 --- a/packages/app/src/app/pages/extensions.tsx +++ b/packages/app/src/app/pages/extensions.tsx @@ -2,6 +2,7 @@ import { Show, createEffect, createMemo, createSignal } from "solid-js"; import { Box, Cpu } from "lucide-solid"; +import { t } from "../../i18n"; import Button from "../components/button"; import McpView, { type McpViewProps } from "./mcp"; import PluginsView, { type PluginsViewProps } from "./plugins"; @@ -46,16 +47,16 @@ export default function ExtensionsView(props: ExtensionsViewProps) {
-

Extensions

+

{t("extensions.title")}

- Apps (MCP) and OpenCode plugins live in one place. + {t("extensions.description")}

0}>
- {connectedAppsCount()} app{connectedAppsCount() === 1 ? "" : "s"} connected + {t("extensions.apps_connected").replace("{count}", String(connectedAppsCount())).replace("{plural}", connectedAppsCount() === 1 ? "" : "s")}
@@ -63,7 +64,7 @@ export default function ExtensionsView(props: ExtensionsViewProps) {
- {pluginCount()} plugin{pluginCount() === 1 ? "" : "s"} + {t("extensions.plugins_count").replace("{count}", String(pluginCount())).replace("{plural}", pluginCount() === 1 ? "" : "s")}
@@ -78,7 +79,7 @@ export default function ExtensionsView(props: ExtensionsViewProps) { aria-pressed={section() === "all"} onClick={() => setSection("all")} > - All + {t("extensions.tab_all")}
@@ -109,7 +110,7 @@ export default function ExtensionsView(props: ExtensionsViewProps) {
- Apps (MCP) + {t("extensions.section_apps")}
- Plugins (OpenCode) + {t("extensions.section_plugins")}
props.openworkServerStatus === "connected" && Boolean(openworkServerClient())); const scopedWorkspaceReady = createMemo(() => Boolean(workspaceId())); - const defaultRoutingDirectory = createMemo(() => props.activeWorkspaceRoot.trim() || "Not set"); + const defaultRoutingDirectory = createMemo(() => props.activeWorkspaceRoot.trim() || t("identities.not_set")); let lastResetKey = ""; const statusLabel = createMemo(() => { - if (healthError()) return "Unavailable"; + if (healthError()) return t("identities.status_unavailable"); const snapshot = health(); - if (!snapshot) return "Unknown"; - return snapshot.ok ? "Running" : "Offline"; + if (!snapshot) return t("identities.status_unknown"); + return snapshot.ok ? t("identities.status_running") : t("identities.status_offline"); }); const isWorkerOnline = createMemo(() => { @@ -239,13 +240,13 @@ export default function IdentitiesView(props: IdentitiesViewProps) { const ts = lastActivityAt(); if (!ts) return "\u2014"; const elapsedMs = Math.max(0, Date.now() - ts); - if (elapsedMs < 60_000) return "Just now"; + if (elapsedMs < 60_000) return t("identities.time_just_now"); const minutes = Math.floor(elapsedMs / 60_000); - if (minutes < 60) return `${minutes}m ago`; + if (minutes < 60) return t("identities.time_minutes_ago").replace("{minutes}", String(minutes)); const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; + if (hours < 24) return t("identities.time_hours_ago").replace("{hours}", String(hours)); const days = Math.floor(hours / 24); - return `${days}d ago`; + return t("identities.time_days_ago").replace("{days}", String(days)); }); const workspaceAgentStatus = createMemo(() => { @@ -324,7 +325,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) { setAgentContent(OPENCODE_ROUTER_AGENT_FILE_TEMPLATE); setAgentDraft(OPENCODE_ROUTER_AGENT_FILE_TEMPLATE); setAgentBaseUpdatedAt(typeof result.updatedAt === "number" ? result.updatedAt : null); - setAgentStatus("Created default messaging agent file."); + setAgentStatus(t("identities.msg_agent_created")); } catch (error) { setAgentError(formatRequestError(error)); } finally { @@ -352,10 +353,10 @@ export default function IdentitiesView(props: IdentitiesViewProps) { setAgentExists(true); setAgentContent(agentDraft()); setAgentBaseUpdatedAt(typeof result.updatedAt === "number" ? result.updatedAt : null); - setAgentStatus("Saved messaging behavior."); + setAgentStatus(t("identities.msg_agent_saved")); } catch (error) { if (error instanceof OpenworkServerError && error.status === 409) { - setAgentError("File changed remotely. Reload and save again."); + setAgentError(t("identities.error_file_changed")); } else { setAgentError(formatRequestError(error)); } @@ -387,7 +388,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) { ...(sendAutoBind() ? { autoBind: true } : {}), }); setSendResult(result); - const base = `Dispatched ${result.sent}/${result.attempted} messages.`; + const base = t("identities.msg_dispatched").replace("{sent}", String(result.sent)).replace("{attempted}", String(result.attempted)); setSendStatus(result.reason?.trim() ? `${base} ${result.reason.trim()}` : base); } catch (error) { setSendError(formatRequestError(error)); @@ -414,9 +415,9 @@ export default function IdentitiesView(props: IdentitiesViewProps) { setTelegramIdentities([]); setTelegramBotUsername(null); setSlackIdentities([]); - setHealthError("Worker scope unavailable. Reconnect using a worker URL or switch to a known worker."); - setTelegramIdentitiesError("Worker scope unavailable."); - setSlackIdentitiesError("Worker scope unavailable."); + setHealthError(t("identities.error_worker_scope_unavailable_long")); + setTelegramIdentitiesError(t("identities.error_worker_scope_unavailable")); + setSlackIdentitiesError(t("identities.error_worker_scope_unavailable")); resetAgentState(); setSendStatus(null); setSendError(null); @@ -441,7 +442,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) { const message = (healthRes.json && typeof (healthRes.json as any).message === "string") ? String((healthRes.json as any).message) - : `OpenCodeRouter health unavailable (${healthRes.status})`; + : t("identities.error_health_unavailable").replace("{status}", String(healthRes.status)); setHealthError(message); } } @@ -450,14 +451,14 @@ export default function IdentitiesView(props: IdentitiesViewProps) { setTelegramIdentities(tgRes.items ?? []); } else { setTelegramIdentities([]); - setTelegramIdentitiesError("Telegram identities unavailable."); + setTelegramIdentitiesError(t("identities.error_telegram_unavailable")); } if (isOpenCodeRouterIdentities(slackRes)) { setSlackIdentities(slackRes.items ?? []); } else { setSlackIdentities([]); - setSlackIdentitiesError("Slack identities unavailable."); + setSlackIdentitiesError(t("identities.error_slack_unavailable")); } if (!agentDirty() && !agentSaving()) { @@ -484,13 +485,13 @@ export default function IdentitiesView(props: IdentitiesViewProps) { const ok = await props.reconnectOpenworkServer(); if (!ok) { - setReconnectError("Reconnect failed. Check OpenWork URL/token and try again."); + setReconnectError(t("identities.error_reconnect_failed")); return; } - setReconnectStatus("Reconnected. Refreshing worker state..."); + setReconnectStatus(t("identities.msg_reconnected_refreshing")); await refreshAll({ force: true }); - setReconnectStatus("Reconnected."); + setReconnectStatus(t("identities.msg_reconnected")); }; const upsertTelegram = async () => { @@ -514,12 +515,12 @@ export default function IdentitiesView(props: IdentitiesViewProps) { if (username) { const normalized = String(username).trim().replace(/^@+/, ""); setTelegramBotUsername(normalized || null); - setTelegramStatus(`Saved (@${normalized || String(username)})`); + setTelegramStatus(t("identities.msg_saved_username").replace("{username}", normalized || String(username))); } else { - setTelegramStatus(result.applied === false ? "Saved (pending apply)." : "Saved."); + setTelegramStatus(result.applied === false ? t("identities.msg_saved_pending") : t("identities.msg_saved")); } } else { - setTelegramError("Failed to save."); + setTelegramError(t("identities.error_save_failed")); } if (typeof result.applyError === "string" && result.applyError.trim()) { setTelegramError(result.applyError.trim()); @@ -549,9 +550,9 @@ export default function IdentitiesView(props: IdentitiesViewProps) { const result = await client.deleteOpenCodeRouterTelegramIdentity(id, identityId); if (result.ok) { setTelegramBotUsername(null); - setTelegramStatus(result.applied === false ? "Deleted (pending apply)." : "Deleted."); + setTelegramStatus(result.applied === false ? t("identities.msg_deleted_pending") : t("identities.msg_deleted")); } else { - setTelegramError("Failed to delete."); + setTelegramError(t("identities.error_delete_failed")); } if (typeof result.applyError === "string" && result.applyError.trim()) { setTelegramError(result.applyError.trim()); @@ -582,9 +583,9 @@ export default function IdentitiesView(props: IdentitiesViewProps) { try { const result = await client.upsertOpenCodeRouterSlackIdentity(id, { botToken, appToken, enabled: slackEnabled() }); if (result.ok) { - setSlackStatus(result.applied === false ? "Saved (pending apply)." : "Saved."); + setSlackStatus(result.applied === false ? t("identities.msg_saved_pending") : t("identities.msg_saved")); } else { - setSlackError("Failed to save."); + setSlackError(t("identities.error_save_failed")); } if (typeof result.applyError === "string" && result.applyError.trim()) { setSlackError(result.applyError.trim()); @@ -614,9 +615,9 @@ export default function IdentitiesView(props: IdentitiesViewProps) { try { const result = await client.deleteOpenCodeRouterSlackIdentity(id, identityId); if (result.ok) { - setSlackStatus(result.applied === false ? "Deleted (pending apply)." : "Deleted."); + setSlackStatus(result.applied === false ? t("identities.msg_deleted_pending") : t("identities.msg_deleted")); } else { - setSlackError("Failed to delete."); + setSlackError(t("identities.error_delete_failed")); } if (typeof result.applyError === "string" && result.applyError.trim()) { setSlackError(result.applyError.trim()); @@ -668,7 +669,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) { {/* ---- Header ---- */}
-

Messaging channels

+

{t("identities.title")}

- Let people reach your worker through messaging apps. Connect a channel and - your worker will automatically read and respond to messages. + {t("identities.description")}

- Workspace scope: {scopedOpenworkBaseUrl().trim() || props.openworkServerUrl.trim() || "Not set"} + {t("identities.workspace_scope").replace("{url}", scopedOpenworkBaseUrl().trim() || props.openworkServerUrl.trim() || t("identities.not_set"))}
{(value) =>
{value()}
} @@ -708,18 +708,14 @@ export default function IdentitiesView(props: IdentitiesViewProps) { {/* ---- Not connected to server ---- */}
-
Connect to an OpenWork server
-
- Identities are available when you are connected to an OpenWork host (openwork). -
+
{t("identities.connect_host")}
+
-
- Workspace ID is required to manage identities. Reconnect with a workspace URL (for example: /w/<workspace-id>) or select a workspace mapped on this host. -
+
@@ -731,7 +727,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) { }`} onClick={() => setActiveTab("general")} > - General + {t("identities.tab_general")}
@@ -760,7 +756,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
- {isWorkerOnline() ? "Worker online" : healthError() ? "Worker unavailable" : "Worker offline"} + {isWorkerOnline() ? t("identities.worker_online") : healthError() ? t("identities.worker_unavailable") : t("identities.worker_offline")}
0} /> 0} /> @@ -804,7 +800,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) { {/* ---- Available channels ---- */}
- Available channels + {t("identities.available_channels")}
@@ -825,15 +821,15 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
- Telegram + {t("identities.telegram")} - Connected + {t("identities.connected")}
- Create a Telegram bot that anyone can message. Great for personal automations and external contacts. + {t("identities.telegram_description")}
- {item.enabled ? "Enabled" : "Disabled"} · {item.running ? "Running" : "Stopped"} + {item.enabled ? t("identities.enabled") : t("mcp.disabled")} · {item.running ? t("status.running") : t("status.stopped")}
@@ -877,7 +873,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) { disabled={telegramSaving() || item.id === "env" || !workspaceId()} onClick={() => void deleteTelegram(item.id)} > - Disconnect + {t("identities.disconnect")}
@@ -888,7 +884,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) { {/* Connected stats summary */}
-
Status
+
{t("identities.status_label")}
i.running) ? "bg-emerald-9" : "bg-gray-8" @@ -896,18 +892,18 @@ export default function IdentitiesView(props: IdentitiesViewProps) { i.running) ? "text-emerald-11" : "text-gray-10" }`}> - {telegramIdentities().some((i) => i.running) ? "Active" : "Stopped"} + {telegramIdentities().some((i) => i.running) ? t("identities.status_active") : t("identities.status_stopped")}
-
Identities
-
{telegramIdentities().length} configured
+
{t("identities.identities_label")}
+
{telegramIdentities().length} {t("identities.configured")}
-
Channel
+
{t("identities.status_channels")}
- {health()?.channels.telegram ? "On" : "Off"} + {health()?.channels.telegram ? t("identities.channel_on") : t("identities.channel_off")}
@@ -924,31 +920,29 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
-
Quick setup
+
{t("identities.quick_setup")}
  1. 1 - - Open @BotFather and run /newbot. - +
  2. 2 - Copy the bot token and paste it below. + {t("identities.telegram_step2")}
  3. 3 - Connect, then send /start to your bot to activate the chat. +
- + setTelegramToken(e.currentTarget.value)} @@ -961,7 +955,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) { checked={telegramEnabled()} onChange={(e) => setTelegramEnabled(e.currentTarget.checked)} /> - Enabled + {t("identities.enabled")} @@ -994,7 +988,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) { class="inline-flex items-center gap-2 rounded-lg border border-gray-4 bg-gray-2/50 px-3 py-2 text-[12px] font-medium text-gray-11 hover:bg-gray-2" > - Open @{telegramBotUsername()} in Telegram + {t("identities.open_telegram_bot").replace("{username}", telegramBotUsername() || "")} )} @@ -1028,15 +1022,15 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
- Slack + {t("identities.slack")} - Connected + {t("identities.connected")}
- Your worker appears as a bot in Slack channels. Team members can message it directly or mention it in threads. + {t("identities.slack_description")}
- {item.enabled ? "Enabled" : "Disabled"} · {item.running ? "Running" : "Stopped"} + {item.enabled ? t("identities.enabled") : t("mcp.disabled")} · {item.running ? t("status.running") : t("status.stopped")}
@@ -1080,7 +1074,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) { disabled={slackSaving() || item.id === "env" || !workspaceId()} onClick={() => void deleteSlack(item.id)} > - Disconnect + {t("identities.disconnect")}
@@ -1091,7 +1085,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) { {/* Connected stats summary */}
-
Status
+
{t("identities.status_label")}
i.running) ? "bg-emerald-9" : "bg-gray-8" @@ -1099,18 +1093,18 @@ export default function IdentitiesView(props: IdentitiesViewProps) { i.running) ? "text-emerald-11" : "text-gray-10" }`}> - {slackIdentities().some((i) => i.running) ? "Active" : "Stopped"} + {slackIdentities().some((i) => i.running) ? t("identities.status_active") : t("identities.status_stopped")}
-
Identities
-
{slackIdentities().length} configured
+
{t("identities.identities_label")}
+
{slackIdentities().length} {t("identities.configured")}
-
Channel
+
{t("identities.status_channels")}
- {health()?.channels.slack ? "On" : "Off"} + {health()?.channels.slack ? t("identities.channel_on") : t("identities.channel_off")}
@@ -1127,13 +1121,13 @@ export default function IdentitiesView(props: IdentitiesViewProps) {

- Connect your Slack workspace to let team members interact with this worker in channels and DMs. + {t("identities.slack_hint")}

- +
- + setSlackEnabled(e.currentTarget.checked)} /> - Enabled + {t("identities.enabled")} @@ -1206,21 +1200,20 @@ export default function IdentitiesView(props: IdentitiesViewProps) { {/* ---- Message routing ---- */}
- Message routing + {t("identities.message_routing")}

- Control which conversations go to which workspace folder. Messages are - routed to the worker's default folder unless you set up rules here. + {t("identities.message_routing_description")}

- Default routing + {t("identities.default_routing")}
- All channels + {t("identities.all_channels")} @@ -1229,19 +1222,15 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
-
- Advanced: reply with /dir <path> in Slack/Telegram to override the directory for a specific chat (limited to this workspace root). -
+
{/* ---- Messaging agent behavior ---- */}
-
Messaging agent behavior
-
- One file per workspace. Add optional first line @agent <id> to route via a specific OpenCode agent. -
+
{t("identities.agent_behavior")}
+
{OPENCODE_ROUTER_AGENT_FILE_PATH} @@ -1251,24 +1240,24 @@ export default function IdentitiesView(props: IdentitiesViewProps) { {(value) => (
- Active scope: workspace · status: {value().loaded ? "loaded" : "missing"} · selected agent: {value().selected || "(none)"} + {t("identities.active_scope").replace("{loaded}", value().loaded ? "loaded" : "missing").replace("{selected}", value().selected || "(none)")}
)}
-
Loading agent file…
+
{t("identities.loading_agent")}
- Agent file not found in this workspace yet. + {t("identities.agent_not_found")}