diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index 8d60bab518..16a6135430 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -296,5 +296,5 @@ golangci-lint run ./... --new-from-rev="" --timeout=5m - `internal/agentctl/AGENTS.md` — agentctl server route groups, adapter model, ACP protocol - `internal/agentctl/server/api/AGENTS.md` — reverse-proxy body rewriting (`Accept-Encoding`), iframe-blocking header stripping - `internal/integrations/AGENTS.md` — playbook for adding a new third-party integration (Jira/Linear pattern) -- `docs/i18n.md` ("Backend") — `internal/i18n` covers only what Go renders straight to a browser (today: the SPA-unavailable error pages). Everything else stays English by design; for new user-facing output prefer a stable error code the frontend translates. +- `docs/i18n.md` ("Backend") — `internal/i18n` covers only what Go renders straight to a browser: the SPA-unavailable error pages and the shared-task artifacts (`share.html`, gist README and description). Both are complete. Everything else stays English by design; for new user-facing output prefer a stable error code the frontend translates. Use `i18n.Tf` for anything carrying a value — never `fmt.Sprintf` a translated string, and never build a plural in Go. A locale for output that outlives the request is resolved once at write time and threaded as an argument, not a context value (ADR `2026-08-01-share-artifact-locale.md`). - `cmd/mock-agent/AGENTS.md` — predefined `/e2e:` scenarios vs inline `e2e:...` scripts, recipe for adding a scenario, and the rebuild-before-e2e requirement diff --git a/apps/backend/internal/i18n/i18n.go b/apps/backend/internal/i18n/i18n.go index 7698802be4..c04cd91af8 100644 --- a/apps/backend/internal/i18n/i18n.go +++ b/apps/backend/internal/i18n/i18n.go @@ -7,9 +7,15 @@ // harder without helping a user. See docs/i18n.md ("Backend"). // // What this package covers is the surface a user reads straight from Go: the -// plain-text error pages served when the SPA bundle cannot be delivered, and any +// plain-text error pages served when the SPA bundle cannot be delivered, the +// shared-task artifacts (share.html, the gist README and description), and any // future server-rendered copy. // +// A shared task is a static file on GitHub — nobody makes a request to us when +// they open it — so its locale is fixed when the share is created, from the +// creator's request, and threaded down to the builders as an argument. See +// docs/decisions/2026-08-01-share-artifact-locale.md. +// // Catalogs mirror the frontend's shape (`locales/.json`, flat // `key: message`) and are embedded, so no runtime file access is needed. package i18n @@ -167,6 +173,94 @@ func T(locale, key string) string { return key } +// Tf returns the message for key in locale with every `{{name}}` placeholder +// replaced by the matching entry in vars. It is the only supported way to build +// a message that carries a value: `fmt.Sprintf` on the result of T would freeze +// the argument order in English, and a translator cannot move a placeholder that +// is not in the catalog. +// +// When vars carries a "count", the i18next plural suffixes are resolved first — +// `key_one` for exactly 1, `key_other` for anything else — falling back to the +// unsuffixed key when the catalog defines no plural forms. That keeps the +// contract identical to the frontend's `t("key", { count })` and is why no call +// site ever appends an English "s" itself. +// +// Values are substituted verbatim; escaping (HTML, Markdown) belongs to the call +// site, which is the only place that knows the target syntax. +func Tf(locale, key string, vars map[string]any) string { + return interpolate(T(locale, pluralKey(locale, key, vars)), vars) +} + +// pluralKey picks the plural variant of key that the catalog actually defines, +// so a message with only a base form still resolves. +func pluralKey(locale, key string, vars map[string]any) string { + count, ok := countVar(vars) + if !ok { + return key + } + // Only an exact 1 is singular. Comparing the original value rather than a + // truncated integer is what stops 1.5 from rendering as "1.5 message". + suffixed := key + "_other" + if count == 1 { + suffixed = key + "_one" + } + // T echoes the key back when it is missing — that is how we detect a catalog + // that never defined the plural forms. + if T(locale, suffixed) != suffixed { + return suffixed + } + return key +} + +// countVar reads the plural selector out of vars as a float64, which is wide +// enough for every Go numeric type a caller might hold (a proto int32, a store +// aggregate uint64, a JSON-decoded float64) and preserves the fractional part +// that decides singular-vs-plural. Non-numeric values are ignored rather than +// erroring: a bad "count" degrades to the unsuffixed key. +func countVar(vars map[string]any) (float64, bool) { + switch n := vars["count"].(type) { + case int: + return float64(n), true + case int8: + return float64(n), true + case int16: + return float64(n), true + case int32: + return float64(n), true + case int64: + return float64(n), true + case uint: + return float64(n), true + case uint8: + return float64(n), true + case uint16: + return float64(n), true + case uint32: + return float64(n), true + case uint64: + return float64(n), true + case float32: + return float64(n), true + case float64: + return n, true + } + return 0, false +} + +// interpolate replaces `{{name}}` with vars[name]. Placeholders with no matching +// var are left in place, which makes the omission visible instead of silently +// producing a sentence with a hole in it. +func interpolate(message string, vars map[string]any) string { + if len(vars) == 0 || !strings.Contains(message, "{{") { + return message + } + pairs := make([]string, 0, len(vars)*2) + for name, value := range vars { + pairs = append(pairs, "{{"+name+"}}", fmt.Sprint(value)) + } + return strings.NewReplacer(pairs...).Replace(message) +} + // Keys lists every key in the source catalog; used by the drift test that keeps // translations in step with `en`. func Keys() []string { diff --git a/apps/backend/internal/i18n/i18n_test.go b/apps/backend/internal/i18n/i18n_test.go index ec9afe49d0..a0a033e480 100644 --- a/apps/backend/internal/i18n/i18n_test.go +++ b/apps/backend/internal/i18n/i18n_test.go @@ -3,6 +3,7 @@ package i18n import ( "net/http" "net/http/httptest" + "strings" "testing" ) @@ -42,6 +43,72 @@ func TestTranslatesAndFallsBack(t *testing.T) { } } +func TestTf(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + locale string + key string + vars map[string]any + want string + }{ + {"interpolates a placeholder", "en", "share.pageTitle", + map[string]any{"title": "Fix the flake"}, "Fix the flake — kandev share"}, + {"count of one selects the singular", "en", "share.messageCount", + map[string]any{"count": 1}, "1 message"}, + {"count of zero selects the plural", "en", "share.messageCount", + map[string]any{"count": 0}, "0 messages"}, + {"count of many selects the plural", "en", "share.messageCount", + map[string]any{"count": 12}, "12 messages"}, + // JSON-decoded numbers arrive as float64; they must still select a form. + {"float count selects a form", "en", "share.messageCount", + map[string]any{"count": float64(1)}, "1 message"}, + // Only an exact 1 is singular. Truncating to an int would pick the + // singular here and render "1.5 message". + {"fractional count is plural", "en", "share.messageCount", + map[string]any{"count": 1.5}, "1.5 messages"}, + // Widths other than int reach Tf from proto fields and store aggregates; + // falling through to the base key would render the plural for every value. + {"int32 count selects the singular", "en", "share.messageCount", + map[string]any{"count": int32(1)}, "1 message"}, + {"uint64 count selects the plural", "en", "share.messageCount", + map[string]any{"count": uint64(4)}, "4 messages"}, + // A key with no _one/_other entries must not resolve to a missing + // suffixed key and echo it back — it falls back to the base message. + {"key without plural forms falls back", "en", "share.untitledTask", + map[string]any{"count": 3}, "Untitled task"}, + // A placeholder with no value stays visible; a silent hole in a + // sentence is much harder to notice in review than "{{title}}". + {"missing var leaves the placeholder", "en", "share.pageTitle", + nil, "{{title}} — kandev share"}, + {"unsupported locale falls back to en", "klingon", "share.messageCount", + map[string]any{"count": 2}, "2 messages"}, + {"non-numeric count is ignored", "en", "share.untitledTask", + map[string]any{"count": "many"}, "Untitled task"}, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := Tf(tc.locale, tc.key, tc.vars); got != tc.want { + t.Fatalf("Tf(%q, %q, %v) = %q, want %q", tc.locale, tc.key, tc.vars, got, tc.want) + } + }) + } +} + +func TestTfPseudoLocaleKeepsPlaceholderValues(t *testing.T) { + t.Parallel() + // The pseudo catalog must translate the message but leave the interpolated + // value alone — a transliterated URL or filename would be a dead pointer. + got := Tf("pseudo", "share.messageCount", map[string]any{"count": 7}) + if !strings.Contains(got, "7") { + t.Fatalf("pseudo render %q dropped the interpolated count", got) + } + if got == Tf("en", "share.messageCount", map[string]any{"count": 7}) { + t.Fatalf("pseudo render is identical to en: %q", got) + } +} + func TestCatalogsHaveMatchingKeys(t *testing.T) { t.Parallel() // Every en key must exist in pseudo, otherwise the QA locale silently shows diff --git a/apps/backend/internal/i18n/locales/en.json b/apps/backend/internal/i18n/locales/en.json index 4025b32bfa..13949f9337 100644 --- a/apps/backend/internal/i18n/locales/en.json +++ b/apps/backend/internal/i18n/locales/en.json @@ -1,4 +1,35 @@ { + "share.brandTag": "shared task", + "share.builtWith": "Built with", + "share.ctaTryKandev": "Try kandev on GitHub", + "share.emptyMessage": "empty", + "share.gistDescription": "kandev share: {{title}}", + "share.gistDescriptionUntitled": "kandev task share", + "share.messageCount_one": "{{count}} message", + "share.messageCount_other": "{{count}} messages", + "share.metaAgent": "Agent", + "share.metaCompleted": "Completed", + "share.metaExecutor": "Executor", + "share.metaModel": "Model", + "share.metaStarted": "Started", + "share.metaWorkflowStep": "Workflow step", + "share.noMessages": "No messages.", + "share.noMetadata": "no metadata", + "share.openRenderedView": "**[Open the rendered view →]({{url}})** for a properly styled conversation.", + "share.openShareHTML": "**Open {{file}}** for the rendered conversation.", + "share.pageTitle": "{{title}} — kandev share", + "share.pitch": "Shared from **[kandev]({{url}})** — the open-source agentic dev environment.", + "share.rawExport": "Raw export:", + "share.redacted": "Redacted:", + "share.redactedBeforePublish": "Redacted before publish:", + "share.roleAssistant": "Assistant", + "share.roleSystem": "System", + "share.roleUser": "User", + "share.roleYou": "You", + "share.sessionDetails": "Session details", + "share.toolOutput": "Tool output", + "share.toolOutputTruncated": "Tool output (truncated)", + "share.untitledTask": "Untitled task", "webapp.assetReadFailed": "Could not read a web asset.", "webapp.devServerUnavailable": "The Vite dev server is unavailable.", "webapp.shellUnavailable": "The Kandev web app is unavailable." diff --git a/apps/backend/internal/i18n/locales/pseudo.json b/apps/backend/internal/i18n/locales/pseudo.json index 843935add6..7aadea0158 100644 --- a/apps/backend/internal/i18n/locales/pseudo.json +++ b/apps/backend/internal/i18n/locales/pseudo.json @@ -1,4 +1,35 @@ { + "share.brandTag": "śĥàŕēď ţàśķ", + "share.builtWith": "Ɓũĩĺţ ŵĩţĥ", + "share.ctaTryKandev": "Ţŕŷ ķàńďēv ōń ĜĩţĤũƀ", + "share.emptyMessage": "ēḿƥţŷ", + "share.gistDescription": "ķàńďēv śĥàŕē: {{title}}", + "share.gistDescriptionUntitled": "ķàńďēv ţàśķ śĥàŕē", + "share.messageCount_one": "{{count}} ḿēśśàĝē", + "share.messageCount_other": "{{count}} ḿēśśàĝēś", + "share.metaAgent": "Àĝēńţ", + "share.metaCompleted": "Ćōḿƥĺēţēď", + "share.metaExecutor": "Ēxēćũţōŕ", + "share.metaModel": "Ḿōďēĺ", + "share.metaStarted": "Śţàŕţēď", + "share.metaWorkflowStep": "Ŵōŕķƒĺōŵ śţēƥ", + "share.noMessages": "Ńō ḿēśśàĝēś.", + "share.noMetadata": "ńō ḿēţàďàţà", + "share.openRenderedView": "**[Ōƥēń ţĥē ŕēńďēŕēď vĩēŵ →]({{url}})** ƒōŕ à ƥŕōƥēŕĺŷ śţŷĺēď ćōńvēŕśàţĩōń.", + "share.openShareHTML": "**Ōƥēń {{file}}** ƒōŕ ţĥē ŕēńďēŕēď ćōńvēŕśàţĩōń.", + "share.pageTitle": "{{title}} — ķàńďēv śĥàŕē", + "share.pitch": "Śĥàŕēď ƒŕōḿ **[ķàńďēv]({{url}})** — ţĥē ōƥēń-śōũŕćē àĝēńţĩć ďēv ēńvĩŕōńḿēńţ.", + "share.rawExport": "Ŕàŵ ēxƥōŕţ:", + "share.redacted": "Ŕēďàćţēď:", + "share.redactedBeforePublish": "Ŕēďàćţēď ƀēƒōŕē ƥũƀĺĩśĥ:", + "share.roleAssistant": "Àśśĩśţàńţ", + "share.roleSystem": "Śŷśţēḿ", + "share.roleUser": "Ũśēŕ", + "share.roleYou": "Ŷōũ", + "share.sessionDetails": "Śēśśĩōń ďēţàĩĺś", + "share.toolOutput": "Ţōōĺ ōũţƥũţ", + "share.toolOutputTruncated": "Ţōōĺ ōũţƥũţ (ţŕũńćàţēď)", + "share.untitledTask": "Ũńţĩţĺēď ţàśķ", "webapp.assetReadFailed": "Ćōũĺď ńōţ ŕēàď à ŵēƀ àśśēţ.", "webapp.devServerUnavailable": "Ţĥē Vĩţē ďēv śēŕvēŕ ĩś ũńàvàĩĺàƀĺē.", "webapp.shellUnavailable": "Ţĥē Ķàńďēv ŵēƀ àƥƥ ĩś ũńàvàĩĺàƀĺē." diff --git a/apps/backend/internal/task/share/backend.go b/apps/backend/internal/task/share/backend.go index 0ea5b9cbdf..9fbbbba49a 100644 --- a/apps/backend/internal/task/share/backend.go +++ b/apps/backend/internal/task/share/backend.go @@ -25,7 +25,11 @@ type Backend interface { // Upload publishes the snapshot and returns the backend-specific // identifier (e.g. gist ID) and the public URL where it can be viewed. - Upload(ctx context.Context, workspaceID string, snap *Snapshot) (externalID, externalURL string, err error) + // + // locale is the share creator's, resolved from the create request. The + // published artifact is a static file, so a reader opening it later makes + // no request we could resolve a locale from — it has to be baked in here. + Upload(ctx context.Context, workspaceID string, snap *Snapshot, locale string) (externalID, externalURL string, err error) // Delete removes a previously-uploaded snapshot. Backends MAY return a // "not found" error which the service detects via IsAlreadyGone. diff --git a/apps/backend/internal/task/share/gist_backend.go b/apps/backend/internal/task/share/gist_backend.go index faaae8e002..ce50ce7b41 100644 --- a/apps/backend/internal/task/share/gist_backend.go +++ b/apps/backend/internal/task/share/gist_backend.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/kandev/kandev/internal/github" + "github.com/kandev/kandev/internal/i18n" ) // GistMaxBytes is a conservative cap on a single snapshot.json file. GitHub @@ -49,7 +50,7 @@ func (b *GistBackend) Name() string { return BackendGitHubGist } // the rendered view served via gist.githack.com — that's the link users // share. The gist itself is preserved in the database via the externalID // so we can still delete it on revoke. -func (b *GistBackend) Upload(ctx context.Context, workspaceID string, snap *Snapshot) (string, string, error) { +func (b *GistBackend) Upload(ctx context.Context, workspaceID string, snap *Snapshot, locale string) (string, string, error) { body, err := json.MarshalIndent(snap, "", " ") if err != nil { return "", "", fmt.Errorf("marshal snapshot: %w", err) @@ -62,17 +63,17 @@ func (b *GistBackend) Upload(ctx context.Context, workspaceID string, snap *Snap return "", "", err } resp, err := client.CreateGist(ctx, github.CreateGistInput{ - Description: gistDescription(snap), + Description: gistDescription(snap, locale), Public: false, Files: map[string]github.GistFile{ // share.html sorts first alphabetically in the gist file list, // which puts the rendered view at the top of the gist page too. - "share.html": {Content: BuildShareHTML(snap)}, + "share.html": {Content: BuildShareHTML(snap, locale)}, "snapshot.json": {Content: string(body)}, // README is built with empty renderedURL — the user's primary // link goes to the rendered view; the README is a fallback for // folks who land on the gist directly. - "README.md": {Content: BuildGistREADME(snap, "")}, + "README.md": {Content: BuildGistREADME(snap, "", locale)}, }, }) if err != nil { @@ -211,11 +212,13 @@ func (b *GistBackend) clientFor(ctx context.Context, workspaceID string) (github return b.client, nil } -func gistDescription(snap *Snapshot) string { +// gistDescription is the one-line summary GitHub shows on the gist page, so +// it is display copy rather than a diagnostic. +func gistDescription(snap *Snapshot, locale string) string { if snap == nil || snap.Task.Title == "" { - return "kandev task share" + return i18n.T(locale, "share.gistDescriptionUntitled") } - return "kandev share: " + snap.Task.Title + return i18n.Tf(locale, "share.gistDescription", map[string]any{"title": snap.Task.Title}) } func nonEmpty(s, fallback string) string { diff --git a/apps/backend/internal/task/share/gist_backend_test.go b/apps/backend/internal/task/share/gist_backend_test.go index 07887c3b6d..0890aa7802 100644 --- a/apps/backend/internal/task/share/gist_backend_test.go +++ b/apps/backend/internal/task/share/gist_backend_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/kandev/kandev/internal/github" + "github.com/kandev/kandev/internal/i18n" ) type recordingGitHubResolver struct { @@ -53,7 +54,7 @@ func TestGistBackend_Upload_CreatesSecretGistWithExpectedFiles(t *testing.T) { mock := github.NewMockClient() b := NewGistBackend(mock) - id, url, err := b.Upload(context.Background(), "workspace-1", sampleSnapshot()) + id, url, err := b.Upload(context.Background(), "workspace-1", sampleSnapshot(), "en") if err != nil { t.Fatalf("upload: %v", err) } @@ -106,7 +107,7 @@ func TestGistBackend_Upload_RejectsOversizedSnapshot(t *testing.T) { big := strings.Repeat("x", GistMaxBytes+1) snap.Messages = []Message{{Role: "user", Blocks: []Block{{Kind: "text", Text: big}}}} - _, _, err := b.Upload(context.Background(), "workspace-1", snap) + _, _, err := b.Upload(context.Background(), "workspace-1", snap, "en") if err == nil { t.Fatal("expected ErrSnapshotTooLarge, got nil") } @@ -122,7 +123,7 @@ func TestGistBackend_Delete_Success(t *testing.T) { t.Parallel() mock := github.NewMockClient() b := NewGistBackend(mock) - id, _, err := b.Upload(context.Background(), "workspace-1", sampleSnapshot()) + id, _, err := b.Upload(context.Background(), "workspace-1", sampleSnapshot(), "en") if err != nil { t.Fatalf("upload: %v", err) } @@ -157,11 +158,11 @@ func TestGistBackend_ResolvesOwningWorkspaceForEveryOperation(t *testing.T) { "workspace-2": second, }} backend := NewWorkspaceGistBackend(resolver) - firstID, _, err := backend.Upload(context.Background(), "workspace-1", sampleSnapshot()) + firstID, _, err := backend.Upload(context.Background(), "workspace-1", sampleSnapshot(), "en") if err != nil { t.Fatalf("workspace-1 upload: %v", err) } - if _, _, err := backend.Upload(context.Background(), "workspace-2", sampleSnapshot()); err != nil { + if _, _, err := backend.Upload(context.Background(), "workspace-2", sampleSnapshot(), "en"); err != nil { t.Fatalf("workspace-2 upload: %v", err) } if err := backend.Delete(context.Background(), "workspace-1", firstID); err != nil { @@ -180,7 +181,39 @@ func TestGistBackend_FailsClosedWhenResolverReturnsNoClient(t *testing.T) { clients: map[string]github.Client{}, allowNil: true, }) - if _, _, err := backend.Upload(context.Background(), "workspace-1", sampleSnapshot()); err == nil { + if _, _, err := backend.Upload(context.Background(), "workspace-1", sampleSnapshot(), "en"); err == nil { t.Fatal("expected upload to fail when the workspace resolver returns no client") } } + +func TestGistDescription_LocalizedInBothForms(t *testing.T) { + t.Parallel() + titled := &Snapshot{Task: TaskMeta{Title: "Fixture"}} + for _, tc := range []struct { + name string + snap *Snapshot + key string + }{ + {"titled", titled, "share.gistDescription"}, + {"untitled", &Snapshot{}, "share.gistDescriptionUntitled"}, + {"nil snapshot", nil, "share.gistDescriptionUntitled"}, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + en := gistDescription(tc.snap, "en") + pseudo := gistDescription(tc.snap, "pseudo") + if en == pseudo { + t.Fatalf("description %q is identical in both locales — it is hardcoded", en) + } + if !strings.Contains(pseudo, i18n.T("pseudo", tc.key)) && + !strings.HasPrefix(pseudo, strings.TrimSuffix(i18n.T("pseudo", tc.key), "{{title}}")) { + t.Fatalf("pseudo description %q does not come from key %q", pseudo, tc.key) + } + }) + } + // The task title is data, not copy: it must survive verbatim. + if got := gistDescription(titled, "pseudo"); !strings.Contains(got, "Fixture") { + t.Fatalf("pseudo description %q dropped or transliterated the task title", got) + } +} diff --git a/apps/backend/internal/task/share/gist_html.go b/apps/backend/internal/task/share/gist_html.go index a2f4dd159e..e5f04912d0 100644 --- a/apps/backend/internal/task/share/gist_html.go +++ b/apps/backend/internal/task/share/gist_html.go @@ -4,6 +4,8 @@ import ( "fmt" "html" "strings" + + "github.com/kandev/kandev/internal/i18n" ) // kandevRepoURL is the public GitHub repo for the project — used in the @@ -13,18 +15,49 @@ import ( // receives a shared task. const kandevRepoURL = "https://github.com/kdlbs/kandev" +// Catalog keys the HTML and Markdown builders share. Keys used in exactly +// one place stay inline at the call site; these are here because the same +// copy has to render identically in both artifacts. +const ( + keyUntitledTask = "share.untitledTask" + keyNoMessages = "share.noMessages" + keyMessageCount = "share.messageCount" + keyToolOutput = "share.toolOutput" + keyToolOutputTruncated = "share.toolOutputTruncated" + keyRoleAssistant = "share.roleAssistant" + keyRoleSystem = "share.roleSystem" +) + +// toolOutputKey picks between the plain and truncated labels. Two whole +// messages rather than a translated "Tool output" with " (truncated)" +// appended: a suffix built in Go is a sentence fragment no translator can +// reorder, and several languages need to. +func toolOutputKey(truncated bool) string { + if truncated { + return keyToolOutputTruncated + } + return keyToolOutput +} + // BuildShareHTML produces a self-contained styled HTML page that renders // the snapshot as a real chat conversation: user bubbles right-aligned, // assistant content left-aligned, consecutive same-role messages fused // into one block, and tool calls collapsed inline rather than rendered // as their own messages. -func BuildShareHTML(snap *Snapshot) string { +// +// locale is the share creator's, captured when the share was created. The +// page is a static file on GitHub, so there is no request to resolve a +// locale from when a reader opens it later — see the package docs on +// internal/i18n. +func BuildShareHTML(snap *Snapshot, locale string) string { if snap == nil { return "kandev share" } var b strings.Builder - b.WriteString("\n\n") - writeHTMLHead(&b, snap) + // Normalize rather than trust the caller: and every lookup + // below must agree on the locale, and i18n.T normalizes internally. + fmt.Fprintf(&b, "\n\n", i18n.Normalize(locale)) + writeHTMLHead(&b, snap, locale) b.WriteString("\n") // Duplicate the stylesheet at the top of as defense-in-depth: some // gist-rendering proxies copy only the body content into their own page, @@ -37,43 +70,55 @@ func BuildShareHTML(snap *Snapshot) string { b.WriteString("\n") - writeHTMLHero(&b, snap) + writeHTMLHero(&b, snap, locale) b.WriteString("
\n") - writeHTMLConversation(&b, snap.Messages) + writeHTMLConversation(&b, snap.Messages, locale) b.WriteString("
\n") - writeHTMLFooter(&b, snap) + writeHTMLFooter(&b, snap, locale) b.WriteString("\n\n") return b.String() } -func writeHTMLHead(b *strings.Builder, snap *Snapshot) { - title := html.EscapeString(nonEmpty(snap.Task.Title, "Untitled task")) +// resolvedTitle is the escaped page title, falling back to the localized +// "Untitled task". Shared by the and the hero so the fallback and the +// escaping can never drift between them. +func resolvedTitle(snap *Snapshot, locale string) string { + return html.EscapeString(nonEmpty(snap.Task.Title, i18n.T(locale, keyUntitledTask))) +} + +func writeHTMLHead(b *strings.Builder, snap *Snapshot, locale string) { + title := resolvedTitle(snap, locale) fmt.Fprintf(b, "\n\n") fmt.Fprintf(b, "\n") - fmt.Fprintf(b, "%s — kandev share\n", title) + // The title is escaped before it goes into the message so the catalog + // entry stays a plain template the translator can reorder. + fmt.Fprintf(b, "%s\n", i18n.Tf(locale, "share.pageTitle", map[string]any{"title": title})) fmt.Fprintf(b, "\n", title) b.WriteString("\n\n") } -func writeHTMLHero(b *strings.Builder, snap *Snapshot) { - title := html.EscapeString(nonEmpty(snap.Task.Title, "Untitled task")) +func writeHTMLHero(b *strings.Builder, snap *Snapshot, locale string) { + title := resolvedTitle(snap, locale) b.WriteString("
\n") b.WriteString("
kandev") - b.WriteString("·shared task
\n") + fmt.Fprintf(b, "·%s\n", + html.EscapeString(i18n.T(locale, "share.brandTag"))) fmt.Fprintf(b, "

%s

\n", title) b.WriteString("
") writeHTMLBadge(b, snap.Session.AgentType) writeHTMLBadge(b, snap.Session.Model) writeHTMLBadge(b, snap.Session.ExecutorType) - writeHTMLBadge(b, fmt.Sprintf("%d messages", len(snap.Messages))) + writeHTMLBadge(b, i18n.Tf(locale, keyMessageCount, map[string]any{"count": len(snap.Messages)})) if completed := formatPtrTime(snap.Session.CompletedAt); completed != "" { writeHTMLBadge(b, completed) } b.WriteString("
\n") if len(snap.Redaction.AppliedRules) > 0 { - fmt.Fprintf(b, "

🛡️ Redacted: %s

\n", + // The emoji is not copy, so it stays in the format string. + fmt.Fprintf(b, "

🛡️ %s %s

\n", + html.EscapeString(i18n.T(locale, "share.redacted")), html.EscapeString(strings.Join(snap.Redaction.AppliedRules, ", "))) } b.WriteString("
\n") @@ -115,49 +160,53 @@ func groupMessages(messages []Message) []messageGroup { return groups } -func writeHTMLConversation(b *strings.Builder, messages []Message) { +func writeHTMLConversation(b *strings.Builder, messages []Message, locale string) { groups := groupMessages(messages) if len(groups) == 0 { - b.WriteString("

No messages.

\n") + fmt.Fprintf(b, "

%s

\n", html.EscapeString(i18n.T(locale, keyNoMessages))) return } for _, g := range groups { - writeHTMLGroup(b, g) + writeHTMLGroup(b, g, locale) } } -func writeHTMLGroup(b *strings.Builder, g messageGroup) { - cls, label, icon := messageRoleAttrs(g.role) +func writeHTMLGroup(b *strings.Builder, g messageGroup, locale string) { + cls, label, icon := messageRoleAttrs(g.role, locale) fmt.Fprintf(b, "
\n", cls) fmt.Fprintf(b, "
%s
\n", icon) b.WriteString("
\n") fmt.Fprintf(b, "
%s
\n", html.EscapeString(label)) for _, block := range g.blocks { - writeHTMLBlock(b, block) + writeHTMLBlock(b, block, locale) } b.WriteString("
\n
\n") } -func messageRoleAttrs(role string) (cls, label, icon string) { +// messageRoleAttrs returns the CSS class, the display label, and the avatar +// for a role. Only the label is copy: cls is a class name the stylesheet +// keys off, icon is an emoji, and the default branch echoes the raw role +// straight from the message store — all three stay untranslated. +func messageRoleAttrs(role, locale string) (cls, label, icon string) { switch role { case roleUser: - return "user", "You", "🧑" + return "user", i18n.T(locale, "share.roleYou"), "🧑" case roleAssistant: - return "assistant", "Assistant", "🤖" + return "assistant", i18n.T(locale, keyRoleAssistant), "🤖" case roleSystem: - return "system", "System", "⚙️" + return "system", i18n.T(locale, keyRoleSystem), "⚙️" } return "other", role, "•" } -func writeHTMLBlock(b *strings.Builder, block Block) { +func writeHTMLBlock(b *strings.Builder, block Block, locale string) { switch block.Kind { case blockKindText: writeHTMLText(b, block.Text) case blockKindToolCall: writeHTMLToolCall(b, block) case blockKindToolResult: - writeHTMLToolResult(b, block) + writeHTMLToolResult(b, block, locale) case blockKindDiff: writeHTMLDiff(b, block) } @@ -182,15 +231,12 @@ func writeHTMLToolCall(b *strings.Builder, block Block) { b.WriteString("\n") } -func writeHTMLToolResult(b *strings.Builder, block Block) { +func writeHTMLToolResult(b *strings.Builder, block Block, locale string) { out := strings.TrimRight(block.Output, "\n") if strings.TrimSpace(out) == "" { return } - label := "Tool output" - if block.Truncated { - label += " (truncated)" - } + label := i18n.T(locale, toolOutputKey(block.Truncated)) b.WriteString("
\n") b.WriteString("📤") fmt.Fprintf(b, "%s", html.EscapeString(label)) @@ -228,9 +274,11 @@ func writeHTMLDiffLine(b *strings.Builder, line string) { fmt.Fprintf(b, "%s\n", cls, html.EscapeString(line)) } -func writeHTMLFooter(b *strings.Builder, snap *Snapshot) { +func writeHTMLFooter(b *strings.Builder, snap *Snapshot, locale string) { b.WriteString("