diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index ae8c19c2..a7c7c88c 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2208,3 +2208,6 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-152', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 10:37:57', '2026-05-23 10:37:57', '2026-05-23 10:37:57', NULL, 'e12bfc4f8896514fb13450e0f83cf6c6', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-152', 'status', 'in_progress', 'done', NULL, '2026-05-23 10:43:52', '2026-05-23 10:43:52', '2026-05-23 10:43:52', NULL, 'edab8c3daaabe51797afbe70b5f9bfde', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-156', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 11:30:35', '2026-05-23 11:30:35', '2026-05-23 11:30:35', NULL, '66000ad896a8ccd2ec91d2ae6c539f01', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-156', 'status', 'in_progress', 'done', NULL, '2026-05-23 12:05:19', '2026-05-23 12:05:19', '2026-05-23 12:05:19', NULL, '24db72624201c89a5c0bcb3b11840275', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-154', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 14:40:01', '2026-05-23 14:40:01', '2026-05-23 14:40:01', NULL, 'aff6dbb3095efd46cf786a53d8622d42', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-155', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 14:40:05', '2026-05-23 14:40:05', '2026-05-23 14:40:05', NULL, 'e46f5eba5bd678190954744c2f8338bc', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index a9b209ee..88a8d04e 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2607,3 +2607,18 @@ Tension to respect: T-146 deliberately pinned each pane to its own --session-id Direction (needs a small decision/spike): detect when Claude switches the active session for a pane and re-bind the TranscriptReader to the new session-id without re-introducing the T-146 cross-pane bug — e.g. via the transcript''s session lineage (parentUuid/leaf, or a session-change marker), or by querying the active session for the pane''s tmux session. Near-term safety to weigh: detect session-mutating commands (/clear, /compact, /resume) and handle/warn rather than silently wedging. Immediate user workaround: restart clide (primary re-binds + --resume to the pre-clear session). Acceptance: after a /clear in a Claude pane, clide follows to the new session and continues to show prompts + responses; secondaries still bind only to their own session (T-146 not regressed); tests cover a simulated session switch re-binding the reader; coverage >= floor.', 'in_progress', 'high', NULL, NULL, 'D-75', '2026-05-23 11:15:08', '2026-05-23 11:30:35', NULL, '8a1383c7726d30afc1336d47b6a4984c', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-156', 'bug', 'T-132', 'Claude pane doesn''t follow session switches (/clear, /compact) — interaction wedges', 'Now that T-153 makes slash commands actually fire, session-mutating commands wedge the Claude pane. /clear makes Claude Code start a NEW session-id (a fresh .jsonl). The composer''s keystrokes still reach the same tmux/claude process, but clide''s TranscriptReader is pinned to the spawn --session-id file (claude_pane binds the reader to a fixed transcriptFile; transcript_reader.dart has zero session-change handling). After /clear: the old transcript freezes, Claude writes to the new session file, and clide keeps tailing the dead file — no responses appear and the pane looks completely broken. Affects /clear and likely /compact, /resume, --fork-session. + +Observed (2026-05-23): a new .jsonl (8b21f12b…) appeared at 13:11 while the prior froze at 13:07, matching a /clear at that time. Pre-T-153 this was hidden because slash commands were pasted as literal text (no-op). + +Tension to respect: T-146 deliberately pinned each pane to its own --session-id to stop secondaries rendering the primary''s transcript (the older newest-jsonl approach crossed panes). So the fix must NOT just follow the newest jsonl globally — it must follow THIS pane''s session lineage when Claude forks it. + +Direction (needs a small decision/spike): detect when Claude switches the active session for a pane and re-bind the TranscriptReader to the new session-id without re-introducing the T-146 cross-pane bug — e.g. via the transcript''s session lineage (parentUuid/leaf, or a session-change marker), or by querying the active session for the pane''s tmux session. Near-term safety to weigh: detect session-mutating commands (/clear, /compact, /resume) and handle/warn rather than silently wedging. Immediate user workaround: restart clide (primary re-binds + --resume to the pre-clear session). + +Acceptance: after a /clear in a Claude pane, clide follows to the new session and continues to show prompts + responses; secondaries still bind only to their own session (T-146 not regressed); tests cover a simulated session switch re-binding the reader; coverage >= floor.', 'done', 'high', NULL, NULL, 'D-75', '2026-05-23 11:15:08', '2026-05-23 12:05:19', NULL, '35311a7e0ad2473412db12bb0c82a4e7', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-154', 'task', 'T-132', 'Surface ClaudeConfig in the Claude status pane', 'The status surface shows LIVE model / permission-mode / context-tokens from the active transcript (T-145, T-150). Complement it with the CONFIGURED side from ClaudeConfig (T-151): available-skills count and/or configured permission/model defaults — static environment state alongside live session state. Reads from ClaudeConfig, not the filesystem; updates when config changes. Acceptance: the status surface reflects ClaudeConfig values, refreshes on config change, and keeps the live transcript-driven fields working; widget test; coverage >= floor. Blocked by T-151.', 'in_progress', 'low', NULL, NULL, 'D-76', '2026-05-23 09:52:32', '2026-05-23 14:40:01', NULL, 'e375208758cf11ba342c0c3cb88f1767', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-155', 'task', NULL, 'Finish i18n catalog plumbing: auto-load extension namespaces, kill ''namespace not registered'' warnings', 'Five builtins (tickets, decisions, git, pql, problems) declare titleKey: ''tab.title'' + i18nNamespace on their TabContribution but ship NO catalog file (lib/kernel/src/i18n/catalog/ has only the 8 tier-0 namespaces) and aren''t in the hand-maintained _tier0Namespaces preload list (lib/main.dart). Result: every boot logs ''i18n: namespace not registered: builtin.X (key: tab.title)'' for each. Tabs still render correctly via the placeholder fallback, so it''s cosmetic console noise — but it recurs for every new builtin because the preload list is maintained by hand. + +Proper fix: when an extension activates, auto-load the i18nNamespace(s) declared by its contributions via i18n.ensureNamespaceLoaded — removing the manual _tier0Namespaces list and the drift it causes. AssetCatalogLoader.load returns empty for a missing file, so auto-preloading alone would just shift the warning to ''missing key tab.title'' — so the missing catalogs must also be created: builtin.{tickets,decisions,git,pql,problems}_en_us.json with at least tab.title plus any other keys those extensions look up. + +Acceptance: clean boot with all builtins active logs zero i18n warnings; a new builtin that declares a titleKey gets its namespace loaded automatically with no manual list edit; the five catalog files exist; tests cover the auto-load-on-activate path and the missing-catalog case; coverage >= floor.', 'in_progress', 'low', NULL, NULL, NULL, '2026-05-23 11:06:50', '2026-05-23 14:40:05', NULL, 'a26532f49dab8052e03654393747ce63', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index c56fc5e6..fe14439d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -136,6 +136,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Fixed +- The git / tickets / decisions / pql / problems tabs no longer log + `i18n: namespace not registered` on boot (T-155). An extension's + localized tab title is now loaded automatically on activation, and the + five missing catalogs were added. - Slash commands sent from the Claude composer now actually run (T-153). Recognised commands are delivered as typed input so Claude's TUI parses them; other input (and stray leading slashes like a `/tmp` path) stays diff --git a/lib/kernel/src/extensions_manager.dart b/lib/kernel/src/extensions_manager.dart index 1af4a6db..e73a0d8a 100644 --- a/lib/kernel/src/extensions_manager.dart +++ b/lib/kernel/src/extensions_manager.dart @@ -146,6 +146,16 @@ class ExtensionManager extends ChangeNotifier { for (final c in ext.contributions) { _applyContribution(c); } + // Eagerly load the i18n catalog for any localized tab this extension + // contributes, so its title resolves without a "namespace not + // registered" warning — and without the namespace having to be listed + // by hand at boot (T-155). + for (final ns in { + for (final c in ext.contributions) + if (c is TabContribution && c.i18nNamespace != null) c.i18nNamespace!, + }) { + await i18n.ensureNamespaceLoaded(ns); + } _activated.add(id); _failed.remove(id); events.emit(ExtensionActivated(id: id)); diff --git a/lib/kernel/src/i18n/catalog/builtin.decisions_en_us.json b/lib/kernel/src/i18n/catalog/builtin.decisions_en_us.json new file mode 100644 index 00000000..f86c67e9 --- /dev/null +++ b/lib/kernel/src/i18n/catalog/builtin.decisions_en_us.json @@ -0,0 +1,3 @@ +{ + "tab.title": { "translation": "Decisions" } +} diff --git a/lib/kernel/src/i18n/catalog/builtin.git_en_us.json b/lib/kernel/src/i18n/catalog/builtin.git_en_us.json new file mode 100644 index 00000000..5df2fc6f --- /dev/null +++ b/lib/kernel/src/i18n/catalog/builtin.git_en_us.json @@ -0,0 +1,3 @@ +{ + "tab.title": { "translation": "Git" } +} diff --git a/lib/kernel/src/i18n/catalog/builtin.pql_en_us.json b/lib/kernel/src/i18n/catalog/builtin.pql_en_us.json new file mode 100644 index 00000000..601439c9 --- /dev/null +++ b/lib/kernel/src/i18n/catalog/builtin.pql_en_us.json @@ -0,0 +1,3 @@ +{ + "tab.title": { "translation": "pql" } +} diff --git a/lib/kernel/src/i18n/catalog/builtin.problems_en_us.json b/lib/kernel/src/i18n/catalog/builtin.problems_en_us.json new file mode 100644 index 00000000..63b8ca81 --- /dev/null +++ b/lib/kernel/src/i18n/catalog/builtin.problems_en_us.json @@ -0,0 +1,3 @@ +{ + "tab.title": { "translation": "Problems" } +} diff --git a/lib/kernel/src/i18n/catalog/builtin.tickets_en_us.json b/lib/kernel/src/i18n/catalog/builtin.tickets_en_us.json new file mode 100644 index 00000000..c90cf1ef --- /dev/null +++ b/lib/kernel/src/i18n/catalog/builtin.tickets_en_us.json @@ -0,0 +1,3 @@ +{ + "tab.title": { "translation": "Tickets" } +} diff --git a/test/helpers/kernel_fixture.dart b/test/helpers/kernel_fixture.dart index 2aecccdd..e68a3e02 100644 --- a/test/helpers/kernel_fixture.dart +++ b/test/helpers/kernel_fixture.dart @@ -18,6 +18,7 @@ class KernelFixture { static Future create({ List? bundledThemes, Map>>? i18nCatalogs, + List? preloadNamespaces, Locale? initialLocale, Locale defaultLocale = const Locale('en', 'US'), }) async { @@ -29,7 +30,7 @@ class KernelFixture { appDir: tempDir, bundledThemes: themes, i18nLoader: InMemoryCatalogLoader(catalogs), - preloadNamespaces: catalogs.keys.toList(), + preloadNamespaces: preloadNamespaces ?? catalogs.keys.toList(), defaultLocale: defaultLocale, initialLocale: initialLocale, daemonClientFactory: (log, events, _) { diff --git a/test/kernel/src/extensions_manager_test.dart b/test/kernel/src/extensions_manager_test.dart index 373e274c..f48511f8 100644 --- a/test/kernel/src/extensions_manager_test.dart +++ b/test/kernel/src/extensions_manager_test.dart @@ -105,6 +105,48 @@ void main() { ); }); + test('activating an extension auto-loads its localized tab namespace (T-155)', () async { + final local = await KernelFixture.create( + i18nCatalogs: { + 'ext.localized': { + const Locale('en', 'US'): { + 'tab.title': {'translation': 'Localized'}, + }, + }, + }, + preloadNamespaces: const [], // loadable, but not preloaded at boot + ); + addTearDown(local.dispose); + + // Not registered yet → string() falls back to the placeholder. + expect( + local.services.i18n.string('tab.title', namespace: 'ext.localized', placeholder: 'fallback'), + 'fallback', + ); + + local.services.extensions.register(_Ext( + id: 'ext.localized', + contributions: [ + TabContribution( + id: 'ext.localized.view', + slot: Slots.workspace, + title: 'Fallback', + titleKey: 'tab.title', + i18nNamespace: 'ext.localized', + build: (_) => const SizedBox.shrink(), + ), + ], + )); + await local.services.extensions.activateAll(); + + // Activation auto-loaded the catalog → resolves, no "namespace not + // registered" warning. + expect( + local.services.i18n.string('tab.title', namespace: 'ext.localized', placeholder: 'fallback'), + 'Localized', + ); + }); + test('deactivate removes contributions from the registry', () async { f.services.extensions.register(_Ext( id: 'ephemeral',