Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion extensions/dsh-browser/src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ import {
type ApprovalPrompt,
type ApprovalRequest,
} from '../security/approval.ts'
import { getUiLocale } from '../i18n.ts'
import { getUiLocale, loadUiLocalePreference, watchUiLocalePreference } from '../i18n.ts'
import { InteractionResponseRouter } from './responses.ts'
import {
actionCoveredByTrustedOrigins,
Expand Down Expand Up @@ -510,6 +510,12 @@ function normalizeSettings(candidate: Settings): Settings {
}
}

// The side panel owns the UI language preference, but approval notifications
// and tool error strings are formatted here, so mirror the same key into this
// worker's cache and keep following panel writes.
void loadUiLocalePreference()
watchUiLocalePreference()

/** Settings load is shared by every lazy connection trigger. */
let settingsLoaded = false
const settingsReady = loadSettings().then((loaded) => {
Expand Down
119 changes: 116 additions & 3 deletions extensions/dsh-browser/src/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,28 @@
/**
* Extension UI language model.
*
* The browser language is only the default: whatever Chrome is set to decides
* the panel the first time it opens. The user can override that with an
* explicit language, which must survive panel reloads and also reach the
* background worker that formats approval notifications and tool errors.
*
* The preference is therefore a small `chrome.storage.local` value owned by
* the panel (like the text scale), mirrored into a module-level cache so the
* synchronous call sites keep working. The background watches the same key and
* refreshes its cache when a panel writes it.
*
* @module
*/

/** Languages supported by the extension UI. */
export type UiLocale = 'en' | 'zh'

/** What the user picked in Settings; `auto` follows the browser language. */
export type UiLocalePreference = 'auto' | UiLocale

/** `chrome.storage.local` key holding the persisted preference. */
export const UI_LOCALE_STORAGE_KEY = 'dshPanelUiLocale'

/**
* Chinese browser locales use Chinese; every other locale deliberately falls
* back to English so an untranslated third language never leaks into the UI.
Expand All @@ -10,12 +32,17 @@ export function localeFromLanguage(language: string | null | undefined): UiLocal
return normalized === 'zh' || normalized.startsWith('zh-') ? 'zh' : 'en'
}

/** Snap a stored or user-supplied value onto a supported preference. */
export function normalizeUiLocalePreference(value: unknown): UiLocalePreference {
return value === 'en' || value === 'zh' ? value : 'auto'
}

/** Read the browser's first preferred language, with the extension UI locale as a fallback. */
export function getUiLocale(): UiLocale {
export function browserLanguage(): string | undefined {
let language: string | undefined
if (typeof navigator !== 'undefined') {
const preferred = navigator.languages?.find((candidate) => candidate.trim() !== '') ?? navigator.language
language = preferred.trim() === '' ? undefined : preferred
language = preferred?.trim() === '' ? undefined : preferred
}
if (language === undefined && typeof chrome !== 'undefined' && chrome.i18n?.getUILanguage !== undefined) {
try {
Expand All @@ -24,5 +51,91 @@ export function getUiLocale(): UiLocale {
// A partially mocked or stale extension context may expose an unusable API.
}
}
return localeFromLanguage(language)
return language
}

/**
* Resolve the preference to the locale text is rendered in. An explicit choice
* wins outright; `auto` keeps the browser-derived behavior.
*/
export function resolveUiLocale(
preference: unknown,
language: string | null | undefined = browserLanguage(),
): UiLocale {
const normalized = normalizeUiLocalePreference(preference)
return normalized === 'auto' ? localeFromLanguage(language) : normalized
}

/** Latest preference loaded by this context; `auto` until storage says otherwise. */
let cachedPreference: UiLocalePreference = 'auto'

/** Update the in-memory cache without touching storage. */
export function setUiLocalePreference(value: unknown): UiLocalePreference {
cachedPreference = normalizeUiLocalePreference(value)
return cachedPreference
}

/** The preference currently in effect, as opposed to the resolved locale. */
export function getUiLocalePreference(): UiLocalePreference {
return cachedPreference
}

/** Locale for text generated right now; the manual choice beats the browser language. */
export function getUiLocale(): UiLocale {
return resolveUiLocale(cachedPreference)
}

/** Load the persisted preference into the cache; falls back to the cache on failure. */
export async function loadUiLocalePreference(): Promise<UiLocalePreference> {
try {
if (typeof chrome === 'undefined' || chrome.storage?.local === undefined) return cachedPreference
const stored = await chrome.storage.local.get(UI_LOCALE_STORAGE_KEY)
return setUiLocalePreference(stored[UI_LOCALE_STORAGE_KEY])
} catch {
return cachedPreference
}
}

/** Persist a choice and apply it to this context immediately. */
export function saveUiLocalePreference(value: unknown): UiLocalePreference {
const preference = setUiLocalePreference(value)
try {
if (typeof chrome !== 'undefined' && chrome.storage?.local !== undefined) {
void chrome.storage.local.set({ [UI_LOCALE_STORAGE_KEY]: preference }).catch(() => {})
}
} catch {
// A storage failure must not break the in-memory switch.
}
return preference
}

/**
* Follow preference changes made by another context (a second side panel, or a
* panel writing while the background worker formats a message). Returns an
* unsubscribe function; a context without `storage.onChanged` gets a no-op.
*/
export function watchUiLocalePreference(onChange?: (preference: UiLocalePreference) => void): () => void {
if (typeof chrome === 'undefined' || chrome.storage?.onChanged === undefined) return () => {}
const listener = (
changes: Record<string, chrome.storage.StorageChange>,
areaName: string,
): void => {
if (areaName !== 'local') return
const change = changes[UI_LOCALE_STORAGE_KEY]
if (change === undefined) return
const preference = setUiLocalePreference(change.newValue)
onChange?.(preference)
}
try {
chrome.storage.onChanged.addListener(listener)
} catch {
return () => {}
}
return () => {
try {
chrome.storage.onChanged.removeListener(listener)
} catch {
// The context may already be torn down; there is nothing left to detach.
}
}
}
47 changes: 44 additions & 3 deletions extensions/dsh-browser/src/panel/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* @module
*/

import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { BRIDGE_SESSION_PURGE_METHOD, DEFAULT_SNAPSHOT_MAX_CHARS } from '@yuxianglin/dsh-bridge-browser/src/protocol.ts'
import type { BridgeCaps } from '@yuxianglin/dsh-bridge-browser/src/protocol.ts'
import type { ServerFrame } from '@yuxianglin/dsh-bridge-browser/src/protocol.ts'
Expand All @@ -17,7 +17,14 @@ import { connectPanel, PanelRpcError, type BridgeInstance, type DevToolsStatus,
import { renderMarkdown } from './markdown.ts'
import whaleUrl from '../../assets/icons/deepseek-256.png'
import type { ApprovalDecision, ApprovalRequest } from '../security/approval.ts'
import { getUiLocale } from '../i18n.ts'
import {
getUiLocale,
getUiLocalePreference,
saveUiLocalePreference,
watchUiLocalePreference,
type UiLocale,
type UiLocalePreference,
} from '../i18n.ts'
import { PANEL_COPY, type PanelCopy } from './strings.ts'
import {
applyUiScale,
Expand Down Expand Up @@ -739,7 +746,8 @@ const ToolActivity = memo(function ToolActivity({ row, copy }: { row: Row; copy:
})

export function App(): React.JSX.Element {
const locale = useMemo(() => getUiLocale(), [])
const [locale, setLocale] = useState<UiLocale>(() => getUiLocale())
const [localePreference, setLocalePreference] = useState<UiLocalePreference>(() => getUiLocalePreference())
const copy = PANEL_COPY[locale]
const [api] = useState<PanelApi>(() => connectPanel())
const [state, setState] = useState<BridgeState>('stopped')
Expand Down Expand Up @@ -921,6 +929,25 @@ export function App(): React.JSX.Element {
saveUiScale(next)
}

// UI language: the choice lives in storage (see `i18n.ts`), so this view
// applies it immediately and keeps the document language/title in sync. The
// watcher catches a change made from another side-panel window.
useEffect(() => {
document.documentElement.lang = locale === 'zh' ? 'zh-CN' : 'en'
document.title = copy.documentTitle
}, [locale, copy])

useEffect(() => watchUiLocalePreference((preference) => {
setLocalePreference(preference)
setLocale(getUiLocale())
}), [])

function changeUiLocale(next: UiLocalePreference): void {
saveUiLocalePreference(next)
setLocalePreference(getUiLocalePreference())
setLocale(getUiLocale())
}

// Settings: seed from storage, then let the panel own the form.
useEffect(() => {
void chrome.storage.local.get('dshSettings').then((stored) => {
Expand Down Expand Up @@ -2545,6 +2572,20 @@ export function App(): React.JSX.Element {
</div>
</div>
<UpdateCard copy={copy.update} />
<div className="settings-panel">
<label>
<span>{copy.settings.language}</span>
<small>{copy.settings.languageHelp}</small>
<select
value={localePreference}
onChange={(event) => changeUiLocale(event.target.value as UiLocalePreference)}
>
<option value="auto">{copy.settings.languageAuto}</option>
<option value="en">{copy.settings.languageEnglish}</option>
<option value="zh">{copy.settings.languageChinese}</option>
</select>
</label>
</div>
<div className="settings-panel">
<label>
<span>{copy.settings.bridgeAddress}</span>
Expand Down
19 changes: 12 additions & 7 deletions extensions/dsh-browser/src/panel/main.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { createRoot } from 'react-dom/client'
import { App } from './App.tsx'
import './styles.css'
import { getUiLocale } from '../i18n.ts'
import { getUiLocale, loadUiLocalePreference } from '../i18n.ts'
import { PANEL_COPY } from './strings.ts'

const locale = getUiLocale()
document.documentElement.lang = locale === 'zh' ? 'zh-CN' : 'en'
document.title = PANEL_COPY[locale].documentTitle
// Read the stored UI language before the first paint: rendering in the browser
// language and then swapping it would flash the wrong locale. `load...` never
// rejects (it falls back to the cached preference), so the panel always mounts.
void loadUiLocalePreference().then(() => {
const locale = getUiLocale()
document.documentElement.lang = locale === 'zh' ? 'zh-CN' : 'en'
document.title = PANEL_COPY[locale].documentTitle

const root = document.getElementById('root')
if (root === null) throw new Error('panel root missing')
createRoot(root).render(<App />)
const root = document.getElementById('root')
if (root === null) throw new Error('panel root missing')
createRoot(root).render(<App />)
})
15 changes: 15 additions & 0 deletions extensions/dsh-browser/src/panel/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ export interface PanelCopy {
back: string
eyebrow: string
title: string
language: string
languageHelp: string
languageAuto: string
languageEnglish: string
languageChinese: string
bridgeAddress: string
bridgeHelp: string
bridgePlaceholder: string
Expand Down Expand Up @@ -367,6 +372,11 @@ const EN: PanelCopy = {
back: 'Back to chat',
eyebrow: 'Browser assistant',
title: 'Settings',
language: 'Language',
languageHelp: 'Language of the assistant panel and its notifications. Takes effect immediately.',
languageAuto: 'Automatic (follow Chrome)',
languageEnglish: 'English',
languageChinese: '中文',
bridgeAddress: 'Bridge address',
bridgeHelp: 'Leave blank to detect a local service automatically',
bridgePlaceholder: 'Auto-detect (desktop beacon 43189–43198 included)',
Expand Down Expand Up @@ -667,6 +677,11 @@ const ZH: PanelCopy = {
back: '返回对话',
eyebrow: '浏览器助手',
title: '设置',
language: '界面语言',
languageHelp: '助手侧栏与通知所用语言,切换后立即生效。',
languageAuto: '自动(跟随 Chrome)',
languageEnglish: 'English',
languageChinese: '中文',
bridgeAddress: '桥地址',
bridgeHelp: '留空时自动检测本机服务',
bridgePlaceholder: '自动检测(含桌面版信标 43189–43198)',
Expand Down
Loading