Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)

## [Unreleased]

### Added
- **Web UI Simplified Chinese.** The session viewer chrome — nav, sidebar, project/session lists, search, settings, memory, insights, session dock, info panel, 404 — is translated. Pick 中文 in the language switcher, pass `?lang=zh-CN`, set `locale: zh-CN` in `~/.config/ccx/config.yaml`, or let `Accept-Language` choose. English stays the default. Traditional Chinese is not a catalog yet.

## [0.18.0] - 2026-10-17

### Added
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ ccx/
parser/ JSONL parsing + tree building
render/ Output formats (HTML, MD, Org, JSON)
web/ HTTP server + embedded templates
i18n/ Web UI catalogs (en, zh-CN)
db/ SQLite persistence (stars)
config/ CLAUDE_CODE_HOME logic
```
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ internal/
├── parser/ JSONL parsing + tree building
├── render/ Output formats (HTML, MD, Org)
├── web/ HTTP server + templates
├── i18n/ Web UI catalogs (en, zh-CN)
├── db/ SQLite persistence (stars, cache)
└── config/ Configuration paths
```
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ ccx doctor # Check setup
```yaml
# ~/.config/ccx/config.yaml
theme: dark
locale: auto # auto | en | zh-CN
show_thinking: collapsed
default_format: html
providers:
Expand Down
60 changes: 60 additions & 0 deletions docs/devlog/2026-09-21-web-i18n-zh.org
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
* [2026-09-21] Dev Log: Web UI Simplified Chinese :WEB:I18N:

** Context
The web UI was English-only: chrome lived as string literals in
=internal/web/templates.go= and friends. The user asked to finish
multilingual support, specifically Chinese.

** Why
A Chinese-speaking operator should be able to browse projects,
sessions, search, and the session dock without translating the
chrome in their head. Session *content* stays as stored.

** What
- =FEAT= =internal/i18n= catalog: English is the source locale;
=zh-CN= covers the same keys. Tests fail on missing or empty
translations.
- =FEAT= Language detection: =?lang==, =ccx-lang= cookie, config
=locale=, then =Accept-Language=. Default remains English.
- =FEAT= Language switcher in the top nav (English / 简体中文).
- =CHANGE= HTML =lang= and =window.CCX_I18N= so client JS uses the
same catalog.

Traditional Chinese is not a catalog. CLI help is still English.

** How
Request-scoped =loc= translator. Top-level renderers are methods on
=loc=; package-level wrappers keep English for existing tests that
call them directly.

** Decisions
| Decision | Alternatives | Rationale | DRI | Timestamp |
| Catalog maps in Go | gettext / x/text/message | Zero new deps; single binary | — | 2026-09-21 |
| zh-CN only | Also zh-TW | No existing Traditional surface | — | 2026-09-21 |
| Web UI first | Also CLI cobra help | CLI is not on the same catalog | — | 2026-09-21 |

** Notes
- =html.EscapeString= turns apostrophes into ='=. Catalog text
in element bodies uses a lighter escape so "Can't" stays readable.
- =zh-TW= / =zh-Hant= do not fall through to Simplified Chinese.
- Pages emit =translate="no"= and =google= =notranslate= so browser
auto-translate does not fight the in-app locale.

** Review changes (2026-10-17, maintainers)
Pushed onto this PR before merge; the notes above describe the PR as
submitted.
- =FIX= Browser translation stays on: no =translate="no"=, no
=notranslate= meta. They disabled it for every language ccx does not
ship, and for session transcripts.
- =FIX= Accept-Language: any =en-*= region is English (=en-CA,zh;q=0.5=
picked Chinese), =zh-Hans-*= is Simplified, and =q=0= entries are
skipped as "not acceptable".
- =FIX= The top-nav search API formats ages in the request's language.
- =CHANGE= One detection path: =locFrom= detects; the middleware only
persists an explicit =?lang= as the cookie. The request-context
plumbing and the test-only English wrappers are gone.
- =CHANGE= One escaper, =html.EscapeString=. Catalog values that carry
markup end in =_markup=, and a test holds every other value free of
=<=.
- =CHANGE= Pages inline only the =js.*= keys client JS reads, not the
whole catalog. Nine unused keys removed.
2 changes: 2 additions & 0 deletions internal/cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ var configShowCmd = &cobra.Command{
fmt.Printf("claude_code_home: %s\n", config.ClaudeHome())
fmt.Printf("codex_home: %s\n", config.CodexHome())
fmt.Printf("theme: %s\n", config.Theme())
fmt.Printf("locale: %s\n", config.Locale())
fmt.Printf("rendering.syntax_highlight: %v\n", config.SyntaxHighlight())
fmt.Printf("rendering.show_thinking: %s\n", config.ShowThinking())
fmt.Printf("rendering.code_theme: %s\n", config.CodeTheme())
Expand Down Expand Up @@ -79,6 +80,7 @@ var configInitCmd = &cobra.Command{
# codex_home: ~/.codex

theme: dark # dark | light | auto
locale: auto # auto | en | zh-CN

rendering:
syntax_highlight: true
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ func initConfig() {
viper.SetDefault("codex_home", config.DefaultCodexHome())
viper.SetDefault("grok_home", config.DefaultGrokHome())
viper.SetDefault("theme", "dark")
viper.SetDefault("locale", "auto")
viper.SetDefault("rendering.syntax_highlight", true)
viper.SetDefault("rendering.show_thinking", "collapsed")
viper.SetDefault("rendering.code_theme", "monokai")
Expand Down
6 changes: 6 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ func Theme() string {
return viper.GetString("theme")
}

// Locale is the configured UI language: auto, en, or zh-CN.
// Empty and "auto" mean "detect from the request".
func Locale() string {
return viper.GetString("locale")
}

func SyntaxHighlight() bool {
return viper.GetBool("rendering.syntax_highlight")
}
Expand Down
2 changes: 2 additions & 0 deletions internal/config/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ type Settings struct {
GrokHome string

Theme string
Locale string
SyntaxHighlight bool
ShowThinking string
CodeTheme string
Expand Down Expand Up @@ -54,6 +55,7 @@ func Load() *Settings {
CodexHome: CodexHome(),
GrokHome: GrokHome(),
Theme: viper.GetString("theme"),
Locale: viper.GetString("locale"),
SyntaxHighlight: viper.GetBool("rendering.syntax_highlight"),
ShowThinking: viper.GetString("rendering.show_thinking"),
CodeTheme: viper.GetString("rendering.code_theme"),
Expand Down
101 changes: 101 additions & 0 deletions internal/i18n/detect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package i18n

import (
"net/http"
"strconv"
"strings"
)

// Detect picks a locale for an HTTP request.
//
// Precedence (most specific first):
// 1. ?lang= query — explicit user action, also persisted as a cookie
// 2. ccx-lang cookie — last explicit choice
// 3. configured locale, when it is a real catalog (not auto/empty)
// 4. Accept-Language
// 5. English
//
// fromQuery is true only when the query param selected the locale,
// so the caller can persist the cookie.
func Detect(r *http.Request, configured string) (t *Translator, fromQuery bool) {
if r != nil {
if raw := strings.TrimSpace(r.URL.Query().Get(QueryParam)); raw != "" {
if code := Normalize(raw); code != "" {
return For(code), true
}
}
if c, err := r.Cookie(CookieName); err == nil && c.Value != "" {
if code := Normalize(c.Value); code != "" {
return For(code), false
}
}
}
if code := Normalize(configured); code != "" {
return For(code), false
}
if r != nil {
if code := matchAcceptLanguage(r.Header.Get("Accept-Language")); code != "" {
return For(code), false
}
}
return Default(), false
}

// matchAcceptLanguage walks a standard Accept-Language header and
// returns the first tag we have a catalog for. q-values are honored
// so "en;q=0.8, zh-CN;q=0.9" selects zh-CN.
func matchAcceptLanguage(header string) string {
if header == "" {
return ""
}
type tagged struct {
code string
q float64
}
var tags []tagged
for _, part := range strings.Split(header, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
name, rest, _ := strings.Cut(part, ";")
q := 1.0
rest = strings.TrimSpace(rest)
if strings.HasPrefix(rest, "q=") {
if v, err := strconv.ParseFloat(strings.TrimSpace(rest[2:]), 64); err == nil {
q = v
}
}
if q <= 0 {
continue // q=0 means "not acceptable"
}
code := Normalize(strings.TrimSpace(name))
if code == "" {
// Prefix match: zh-HK → no Traditional catalog, skip.
// zh → already handled by Normalize.
continue
}
tags = append(tags, tagged{code: code, q: q})
}
bestQ := -1.0
best := ""
for _, t := range tags {
if t.q > bestQ {
bestQ = t.q
best = t.code
}
}
return best
}

// Cookie builds the persistence cookie for an explicit language choice.
func Cookie(code string) *http.Cookie {
return &http.Cookie{
Name: CookieName,
Value: Normalize(code),
Path: "/",
MaxAge: 365 * 24 * 60 * 60,
SameSite: http.SameSiteLaxMode,
HttpOnly: false, // language switcher is a <select> in the page
}
}
Loading
Loading