From 8d9fc4195be735e83be6c283817c4ba0003703fd Mon Sep 17 00:00:00 2001 From: codeman-local Date: Tue, 21 Jul 2026 02:49:25 +0800 Subject: [PATCH] feat(ui): add custom branding and Chinese localization --- .changeset/custom-name-bilingual-ui.md | 5 + CLAUDE.md | 6 +- README.md | 2 +- README.zh-CN.md | 2 +- scripts/build.mjs | 2 + src/web/public/app.js | 11 +- src/web/public/i18n.js | 957 +++++++++++++++++++++++++ src/web/public/index.html | 19 + src/web/public/notification-manager.js | 6 +- src/web/public/settings-ui.js | 24 + src/web/public/styles.css | 18 + src/web/schemas.ts | 16 + src/web/server.ts | 11 +- test/i18n-branding.test.ts | 123 ++++ 14 files changed, 1191 insertions(+), 11 deletions(-) create mode 100644 .changeset/custom-name-bilingual-ui.md create mode 100644 src/web/public/i18n.js create mode 100644 test/i18n-branding.test.ts diff --git a/.changeset/custom-name-bilingual-ui.md b/.changeset/custom-name-bilingual-ui.md new file mode 100644 index 00000000..5e7b1a51 --- /dev/null +++ b/.changeset/custom-name-bilingual-ui.md @@ -0,0 +1,5 @@ +--- +"aicodeman": patch +--- + +Add a synced custom display name and a per-device English/Simplified Chinese browser UI language picker under App Settings → Display. diff --git a/CLAUDE.md b/CLAUDE.md index c556b3c9..28e90abf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,7 +140,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | **Attachments** | `src/attachment-registry.ts`, `src/attachment-magic.ts`, `src/generated-artifact-attachments.ts` (Codex `Saved to:` artifacts), `src/session-attachment-history.ts`, `src/document-preview-cache.ts`, `src/document-thumbnailer.ts`, `src/document-conversion-limiter.ts`, `src/config/attachment-guard.ts` | See Key Patterns | | **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`, the CLAUDE.md scaffold generated into new cases) | | | **Web** | `src/web/server.ts` ★, `src/web/sse-events.ts`, `src/web/routes/*.ts` (20 route modules + barrel; `session-routes.ts` ★), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts`, `src/web/self-update.ts`, `src/web/plan-usage-latest.ts`, `src/web/ws-connection-registry.ts` (per-tab WS supersede), `src/web/heic-jpeg-converter.ts` + `heic-jpeg-worker.ts` (HEIC→JPEG off-thread) | | -| **Frontend** | `src/web/public/app.js` (~4K lines, core) + 6 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`, `sanitize-html.js` — DOMPurify mXSS allowlist, COD-56) + 10 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `ultracode-panel.js`, `cron-ui.js`, `settings-ui.js`, `panels-ui.js`, `admin-ui.js`, `session-ui.js`) + 6 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `ultracode-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | `ultracode-windows.js` = floating run windows w/ tab connector lines (additional to the dock panel) | +| **Frontend** | `src/web/public/app.js` (~4K lines, core) + 7 infra modules (`constants.js`, `i18n.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`, `sanitize-html.js` — DOMPurify mXSS allowlist, COD-56) + 10 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `ultracode-panel.js`, `cron-ui.js`, `settings-ui.js`, `panels-ui.js`, `admin-ui.js`, `session-ui.js`) + 6 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `ultracode-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | `i18n.js` owns English/Simplified Chinese UI localization + user-facing branding; `ultracode-windows.js` = floating run windows w/ tab connector lines | | **Types** | `src/types/index.ts` (barrel) → 19 domain files (incl. `workflow-run.ts`, `search.ts`, `cron.ts`, `user.ts`); also `src/types.ts` root re-export | See `@fileoverview` in index.ts | ★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`. @@ -208,7 +208,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ### Frontend -Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `ultracode-panel.js`(11.5) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `image-input.js`(16). `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). +Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `ultracode-panel.js`(11.5) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). **Command palette + shortcut registry** (COD-151/153/157/192, #146): `Ctrl/Cmd/Alt+K` opens the session palette (fuzzy search over live sessions; "Browse all sessions" → the Session Manager modal backed by `GET /api/sessions/unified`); the quick-start case ` + +
+ Interface Language + +
+
Appearance
@@ -2502,6 +2520,7 @@

Save Respawn Preset

+ diff --git a/src/web/public/notification-manager.js b/src/web/public/notification-manager.js index cf3ce2cc..8c28e915 100644 --- a/src/web/public/notification-manager.js +++ b/src/web/public/notification-manager.js @@ -330,8 +330,10 @@ class NotificationManager { if (now - this.lastBrowserNotifTime < BROWSER_NOTIF_RATE_LIMIT_MS) return; this.lastBrowserNotifTime = now; - const notif = new Notification(`${this.originalTitle}: ${title}`, { - body, + const localizedTitle = window.codemanT?.(title) || title; + const localizedBody = window.codemanT?.(body) || body; + const notif = new Notification(`${this.originalTitle}: ${localizedTitle}`, { + body: localizedBody, tag, // Groups same-tag notifications icon: '/favicon.ico', silent: true, // We handle audio ourselves diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 1d16cf86..74e90b3f 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -297,6 +297,8 @@ Object.assign(CodemanApp.prototype, { openAppSettings() { // Load current settings const settings = this.loadAppSettingsFromStorage(); + document.getElementById('appSettingsDisplayName').value = settings.displayName || 'Codeman'; + document.getElementById('appSettingsLanguage').value = settings.language === 'zh-CN' ? 'zh-CN' : 'en'; document.getElementById('appSettingsClaudeMdPath').value = settings.defaultClaudeMdPath || ''; document.getElementById('appSettingsDefaultDir').value = settings.defaultWorkingDir || ''; // Use device-aware defaults for display settings (mobile has different defaults) @@ -1420,6 +1422,12 @@ Object.assign(CodemanApp.prototype, { // as "previously off" — used below to detect a real OFF→ON flip. const _prevWebglEnabled = (_prev.webglRendererEnabled ?? true) === true; const settings = { + displayName: window.CodemanI18n?.normalizeDisplayName( + document.getElementById('appSettingsDisplayName').value + ) || 'Codeman', + language: window.CodemanI18n?.normalizeLanguage( + document.getElementById('appSettingsLanguage').value + ) || 'en', defaultClaudeMdPath: document.getElementById('appSettingsClaudeMdPath').value.trim(), defaultWorkingDir: document.getElementById('appSettingsDefaultDir').value.trim(), ralphTrackerEnabled: document.getElementById('appSettingsRalphEnabled').checked, @@ -1592,6 +1600,7 @@ Object.assign(CodemanApp.prototype, { // Apply header visibility immediately this.applyHeaderVisibilitySettings(); this.applySkin(); + this.applyLocalization(); this.applyTabWrapSettings(); this._updateTokensImmediate(); // Re-render token display (picks up showCost change) this.applyMonitorVisibility(); @@ -1620,6 +1629,7 @@ Object.assign(CodemanApp.prototype, { cjkInputEnabled: _cjk, extendedKeyboardBar: _ekb, skin: _skin, + language: _language, showPlanUsageLimits: _pul, showAttachmentsButton: _ahb, showFileViewerButton: _fvb, @@ -1860,6 +1870,19 @@ Object.assign(CodemanApp.prototype, { if (typeof this.applyTerminalSkin === 'function') this.applyTerminalSkin(skin); }, + // Apply the per-device language and the synced user-facing product name. + // The i18n layer updates both existing static nodes and future dynamic DOM. + applyLocalization() { + const settings = this.loadAppSettingsFromStorage(); + const result = window.CodemanI18n?.configure({ + language: settings.language, + displayName: settings.displayName, + }); + if (result && this.notificationManager) { + this.notificationManager.originalTitle = document.title; + } + }, + applyHeaderVisibilitySettings() { const settings = this.loadAppSettingsFromStorage(); const defaults = this.getDefaultSettings(); @@ -2208,6 +2231,7 @@ Object.assign(CodemanApp.prototype, { 'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents', 'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar', 'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled', + 'language', 'terminalWheelLocalScrollback', 'showSessionButton', 'showAwayDigestButton', 'showCronButton', ]); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 4f2f3554..c854cb7c 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -4530,6 +4530,24 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { text-overflow: ellipsis; } +.settings-inline-input, +.settings-inline-select { + width: min(190px, 52%); + min-width: 0; + padding: 0.3rem 0.45rem; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg-input); + color: var(--text); + font: inherit; +} + +.settings-inline-input:focus, +.settings-inline-select:focus { + outline: none; + border-color: var(--accent); +} + /* Skin picker: full-width row (not squeezed into a half-width grid cell) with an auto-width select, so the "Skin" label keeps its space instead of truncating. */ .settings-item-skin { diff --git a/src/web/schemas.ts b/src/web/schemas.ts index d43fd3e2..cbbcf6d2 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -650,6 +650,22 @@ const NotificationEventSchema = z export const SettingsUpdateSchema = z .object({ + // User-facing product branding. This changes browser/UI copy only; package, + // CLI, API, storage, and protocol identifiers remain Codeman. + displayName: z + .string() + .trim() + .min(1) + .max(40) + .refine( + (value) => + Array.from(value).every((character) => { + const codePoint = character.codePointAt(0); + return codePoint !== undefined && codePoint > 31 && codePoint !== 127; + }), + 'Display name must not contain control characters' + ) + .optional(), // Paths defaultClaudeMdPath: z.string().max(500).optional(), defaultWorkingDir: z.string().max(500).optional(), diff --git a/src/web/server.ts b/src/web/server.ts index 07d3121e..4697ffef 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -288,7 +288,7 @@ export class WebServer extends EventEmitter { private teamWatcher: TeamWatcher = new TeamWatcher(); private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null; private readonly titleHostname: string; - private readonly windowTitle: string; + private windowTitle: string; private readonly indexHtmlTemplate: string; private readonly allowUnauthenticatedNetwork: boolean; private _pasteImageGcStop: (() => void) | null = null; @@ -1220,6 +1220,13 @@ export class WebServer extends EventEmitter { } private async renderIndexHtml(soloSessionId?: string): Promise { + // Detached-session windows intentionally skip App Settings during server + // rendering (their client bootstrap loads the synced name moments later). + const persistedSettings: Record = soloSessionId ? {} : await this.readSettings(true); + const configuredDisplayName = + typeof persistedSettings.displayName === 'string' ? persistedSettings.displayName.trim() : ''; + const displayName = configuredDisplayName || 'Codeman'; + this.windowTitle = `${displayName === 'Codeman' ? 'codeman' : displayName}:${this.titleHostname}`; let html = this.indexHtmlTemplate.replace( 'Codeman', `${escapeHtmlText(this.windowTitle)}` @@ -1233,7 +1240,7 @@ export class WebServer extends EventEmitter { // moments ago triggers a reload here, and the cached value would render the // pre-toggle state (e.g. the gesture bundle wouldn't inject until a 2nd // reload). Skipped for solo popups (their header differs). - const settings: Record = soloSessionId ? {} : await this.readSettings(true); + const settings: Record = soloSessionId ? {} : persistedSettings; // Multi-monitor header button: carries the `btn-multimonitor--hidden` class // in the template by default (App Settings → Display → "Header Displays"); // reveal by stripping that class when the user enabled it. Matching a unique diff --git a/test/i18n-branding.test.ts b/test/i18n-branding.test.ts new file mode 100644 index 00000000..45f95908 --- /dev/null +++ b/test/i18n-branding.test.ts @@ -0,0 +1,123 @@ +/** + * Regression coverage for the browser i18n layer and custom display-name setting. + * Port: N/A (JSDOM + direct schema/server render calls only). + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { SettingsUpdateSchema } from '../src/web/schemas.js'; +import { WebServer } from '../src/web/server.js'; + +const i18nSource = readFileSync(new URL('../src/web/public/i18n.js', import.meta.url), 'utf8'); +const indexSource = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf8'); +const settingsSource = readFileSync(new URL('../src/web/public/settings-ui.js', import.meta.url), 'utf8'); + +function makeDom(body = '') { + const dom = new JSDOM( + `codeman:test-host${body}`, + { + runScripts: 'outside-only', + url: 'http://localhost/', + } + ); + vm.runInContext(i18nSource, dom.getInternalVMContext(), { filename: 'i18n.js' }); + return dom; +} + +describe('custom display name and browser localization', () => { + const previousDataDir = process.env.CODEMAN_DATA_DIR; + + afterEach(() => { + if (previousDataDir === undefined) delete process.env.CODEMAN_DATA_DIR; + else process.env.CODEMAN_DATA_DIR = previousDataDir; + }); + + it('accepts bounded Chinese display names and rejects invalid values', () => { + expect(SettingsUpdateSchema.safeParse({ displayName: '小码助手' }).success).toBe(true); + expect(SettingsUpdateSchema.safeParse({ displayName: ' 小码助手 ' }).data).toMatchObject({ + displayName: '小码助手', + }); + expect(SettingsUpdateSchema.safeParse({ displayName: '' }).success).toBe(false); + expect(SettingsUpdateSchema.safeParse({ displayName: '名'.repeat(41) }).success).toBe(false); + expect(SettingsUpdateSchema.safeParse({ displayName: 'unsafe\nname' }).success).toBe(false); + }); + + it('translates static and dynamic UI, restores English, and leaves terminal/user content untouched', async () => { + const dom = makeDom(` + + +
Home
+
Run
+
+ Run + Home + Settings saved + Home +
+
+ `); + const { window } = dom; + const api = window.CodemanI18n; + api.start(); + api.configure({ language: 'zh-CN', displayName: '小码助手' }); + + expect(window.document.querySelector('.logo')?.textContent).toBe('小码助手'); + expect(window.document.getElementById('settings')?.textContent).toBe('应用设置'); + expect(window.document.getElementById('settings')?.getAttribute('title')).toBe('应用设置'); + expect(window.document.getElementById('user')?.textContent).toBe('Home'); + expect(window.document.querySelector('.xterm')?.textContent).toBe('Run'); + expect(window.document.querySelector('.history-item-title')?.textContent).toBe('Run'); + expect(window.document.querySelector('.history-detail-prompt')?.textContent).toBe('Settings saved'); + expect(window.document.querySelector('.history-item')?.getAttribute('title')).toBe('Run'); + expect(window.document.title).toBe('小码助手:test-host'); + + const dynamicButton = window.document.createElement('button'); + dynamicButton.textContent = 'Settings saved'; + dynamicButton.title = 'Open Codeman across all displays'; + window.document.getElementById('dynamic')?.appendChild(dynamicButton); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + expect(dynamicButton.textContent).toBe('设置已保存'); + expect(dynamicButton.title).toBe('在所有显示器上打开 小码助手'); + + api.configure({ language: 'en', displayName: 'Workbench' }); + expect(window.document.getElementById('settings')?.textContent).toBe('App Settings'); + expect(window.document.querySelector('.logo')?.textContent).toBe('Workbench'); + expect(dynamicButton.textContent).toBe('Settings saved'); + expect(window.document.title).toBe('Workbench:test-host'); + dom.window.close(); + }); + + it('renders hostile-looking names as text rather than HTML', () => { + const dom = makeDom(''); + const api = dom.window.CodemanI18n; + api.start(); + api.configure({ displayName: '', language: 'en' }); + const logo = dom.window.document.querySelector('.logo'); + expect(logo?.textContent).toBe(''); + expect(logo?.querySelector('img')).toBeNull(); + dom.window.close(); + }); + + it('wires the two settings with language kept per-device and display name synced', () => { + expect(indexSource).toContain('id="appSettingsDisplayName"'); + expect(indexSource).toContain('id="appSettingsLanguage"'); + expect(indexSource).toContain(''); + expect(settingsSource).toMatch(/language:\s*_language/); + expect(settingsSource).toContain("'language',"); + expect(settingsSource).not.toMatch(/displayName:\s*_displayName/); + }); + + it('uses the escaped custom name in the server-rendered window title', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'codeman-brand-title-')); + process.env.CODEMAN_DATA_DIR = dataDir; + writeFileSync(join(dataDir, 'settings.json'), JSON.stringify({ displayName: '小码<&助手' })); + const server = new WebServer(0, false, true, '127.0.0.1', 'laptop'); + const html = await (server as unknown as { renderIndexHtml: () => Promise }).renderIndexHtml(); + expect(html).toContain('小码<&助手:laptop'); + expect(html).not.toContain('小码<&助手:laptop'); + }); +});