diff --git a/apps/desktop/src/lib/session-title-utils.ts b/apps/desktop/src/lib/session-title-utils.ts index ce856512f7..26f975adeb 100644 --- a/apps/desktop/src/lib/session-title-utils.ts +++ b/apps/desktop/src/lib/session-title-utils.ts @@ -18,6 +18,12 @@ const LEGACY_DEFAULT_TITLES = new Set([ "nova conversa", "yeni görev", "yeni sohbet", + "neue aufgabe", + "nouvelle tâche", + "nueva tarea", + "neuer chat", + "nuevo chat", + "nouvelle discussion", ]); /** Sidebar/topbar title budget for the local first-prompt fallback. */ const PROMPT_FALLBACK_TITLE_LENGTH = 48; diff --git a/apps/desktop/test/session-title-prompt-fallback.test.mjs b/apps/desktop/test/session-title-prompt-fallback.test.mjs index 9ccf46094f..a4d7ee5349 100644 --- a/apps/desktop/test/session-title-prompt-fallback.test.mjs +++ b/apps/desktop/test/session-title-prompt-fallback.test.mjs @@ -96,3 +96,52 @@ test("automatic AI titles stay plugin-owned", () => { assert.doesNotMatch(store, /triggerAutoTitleSummarization/); assert.doesNotMatch(main, /IPC\.invoke\.sessionSummarizeTitle/); }); + +test("the German, French and Spanish placeholders are recognized as default titles (#1755)", async (t) => { + // The renderer lowercases chat.untitledTask before persisting; the host + // list and this legacy set must recognize those forms or auto-titling + // never runs in those locales. nav.newChat resolves through the same + // two-sided comparison. + const locales = [ + ["de", "Neue Aufgabe", "Neuer Chat"], + ["fr", "Nouvelle tâche", "Nouvelle discussion"], + ["es", "Nueva tarea", "Nuevo chat"], + ]; + for (const [locale, untitled, newChat] of locales) { + assert.equal( + catalogs[locale].chat.untitledTask, + untitled, + `${locale} catalog must still own its untitledTask label`, + ); + assert.equal( + catalogs[locale].nav.newChat, + newChat, + `${locale} catalog must still own its newChat label`, + ); + assert.equal(isDefaultSessionTitle(untitled), true, `${locale} untitledTask`); + assert.equal( + isDefaultSessionTitle(untitled.toLowerCase()), + true, + `${locale} untitledTask lowercased`, + ); + assert.equal(isDefaultSessionTitle(newChat), true, `${locale} newChat`); + assert.equal( + isDefaultSessionTitle(newChat.toLowerCase()), + true, + `${locale} newChat lowercased`, + ); + } + + // Host side stays in sync: both label families, stored lowercase, compared + // through to_lowercase so a non-ASCII uppercase letter cannot slip past. + assert.match( + host, + /"neue aufgabe",\s*\n\s*"nouvelle tâche",\s*\n\s*"nueva tarea",\s*\n\s*"neuer chat",\s*\n\s*"nuevo chat",\s*\n\s*"nouvelle discussion",/, + "PLACEHOLDER_TITLES must carry the German, French and Spanish labels for both keys", + ); + assert.match( + host, + /to_lowercase\(\)/, + "is_default_title compares through to_lowercase", + ); +}); diff --git a/crates/host-core/src/db/session_title_source_migration.rs b/crates/host-core/src/db/session_title_source_migration.rs index 8c853a6a8d..0cc485c8e7 100644 --- a/crates/host-core/src/db/session_title_source_migration.rs +++ b/crates/host-core/src/db/session_title_source_migration.rs @@ -23,7 +23,9 @@ fn migrate_v22_to_v23_tx(tx: &Transaction<'_>) -> Result<()> { WHEN title IN ('', 'New task', 'New chat', '新建任务', '新对话', '新建任務', '新對話', '새 작업', '새 채팅', 'Tarefa sem título', 'Nova conversa', - 'Yeni görev', 'Yeni sohbet') THEN 'default' + 'Yeni görev', 'Yeni sohbet', 'Neue Aufgabe', + 'Nouvelle tâche', 'Nueva tarea', 'Neuer Chat', + 'Nuevo chat', 'Nouvelle discussion') THEN 'default' ELSE 'manual' END", [], diff --git a/crates/host-core/src/sessions.rs b/crates/host-core/src/sessions.rs index df89a3dcc5..fd7a3650d1 100644 --- a/crates/host-core/src/sessions.rs +++ b/crates/host-core/src/sessions.rs @@ -342,29 +342,39 @@ pub struct SearchHit { /// The renderer creates a session with its localized `chat.untitledTask` label /// and also recognizes `nav.newChat`, so host-core has to recognize the same /// values: it owns title eligibility and cannot read the renderer catalog when -/// it decides whether a title may still be replaced. German, Spanish, and -/// French fall back to the English label. Keep this in sync with the migration -/// list in `db/session_title_source_migration.rs` and `LEGACY_DEFAULT_TITLES` -/// in the renderer. -const PLACEHOLDER_TITLES: [&str; 13] = [ +/// it decides whether a title may still be replaced. Every entry is stored +/// lowercased and compared through `to_lowercase`, because both localized +/// keys the renderer writes — `chat.untitledTask` and `nav.newChat` — have +/// their own label per locale, and a non-ASCII uppercase letter (a future +/// "Ñö..." entry) would slip past ASCII-case-insensitive matching (#1755). +/// Keep this in sync with the migration list in +/// `db/session_title_source_migration.rs` and `LEGACY_DEFAULT_TITLES` in the +/// renderer. +const PLACEHOLDER_TITLES: [&str; 19] = [ "", - "New task", - "New chat", + "new task", + "new chat", "新建任务", "新对话", "新建任務", "新對話", "새 작업", "새 채팅", - "Tarefa sem título", - "Nova conversa", - "Yeni görev", - "Yeni sohbet", + "tarefa sem título", + "nova conversa", + "yeni görev", + "yeni sohbet", + "neue aufgabe", + "nouvelle tâche", + "nueva tarea", + "neuer chat", + "nuevo chat", + "nouvelle discussion", ]; fn is_default_title(title: &str) -> bool { - let t = title.trim(); - PLACEHOLDER_TITLES.iter().any(|p| p.eq_ignore_ascii_case(t)) + let t = title.trim().to_lowercase(); + PLACEHOLDER_TITLES.iter().any(|p| *p == t) } // ---- UiMessage ⇄ transcript record mapping ----------------------------------- @@ -4269,6 +4279,19 @@ pub fn get_token_usage_history( #[cfg(test)] mod tests { use super::*; + + #[test] + fn default_title_matches_the_german_french_and_spanish_placeholders() { + // The renderer lowercases `chat.untitledTask` before persisting (#1755); + // the nav.newChat labels resolve through the same to_lowercase path. + assert!(is_default_title("neue aufgabe")); + assert!(is_default_title("Nouvelle tâche")); + assert!(is_default_title("nueva tarea")); + assert!(is_default_title("Neuer Chat")); + assert!(is_default_title("nuevo chat")); + assert!(is_default_title("Nouvelle discussion")); + assert!(!is_default_title("real session name")); + } use crate::workspace::simple_canonicalize; fn test_db() -> Database { diff --git a/packages/shared/src/types/settings.ts b/packages/shared/src/types/settings.ts index 2bf997b9e3..26d6d96cb4 100644 --- a/packages/shared/src/types/settings.ts +++ b/packages/shared/src/types/settings.ts @@ -172,13 +172,14 @@ export type AppSettings = { preventScreenSleep?: boolean; /** * Sidebar project "running" affordances. The master switch gates both - * sub-switches; absent means disabled, so a fresh install shows a clean - * sidebar until the user opts in. + * sub-switches; absent means enabled — the renderer normalizes + * `!== false` to on, so a fresh install shows the running badge and rail + * until the user turns them off (#1749). */ sidebarRunningIndicators?: boolean; - /** Count badge beside a project name. Only honored while the master is on. */ + /** Count badge beside a project name. On unless explicitly disabled. */ sidebarRunningBadge?: boolean; - /** Activity rail down the project group. Only honored while the master is on. */ + /** Activity rail down the project group. On unless explicitly disabled. */ sidebarRunningRail?: boolean; /** * Last-reply date shown at the right edge of session rows. Absent means