@data-club/ai-hub 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/dist/auth/locales/cs.d.ts +16 -0
  2. package/dist/auth/locales/cs.d.ts.map +1 -0
  3. package/dist/auth/locales/cs.js +394 -0
  4. package/dist/auth/locales/cs.js.map +1 -0
  5. package/dist/auth/locales/index.d.ts +24 -0
  6. package/dist/auth/locales/index.d.ts.map +1 -0
  7. package/dist/auth/locales/index.js +31 -0
  8. package/dist/auth/locales/index.js.map +1 -0
  9. package/dist/auth/locales/sk.d.ts +16 -0
  10. package/dist/auth/locales/sk.d.ts.map +1 -0
  11. package/dist/auth/locales/sk.js +394 -0
  12. package/dist/auth/locales/sk.js.map +1 -0
  13. package/dist/auth/texts.d.ts +24 -2
  14. package/dist/auth/texts.d.ts.map +1 -1
  15. package/dist/auth/texts.js +20 -7
  16. package/dist/auth/texts.js.map +1 -1
  17. package/dist/client/AIHubClient.d.ts +11 -0
  18. package/dist/client/AIHubClient.d.ts.map +1 -1
  19. package/dist/client/AIHubClient.js +10 -0
  20. package/dist/client/AIHubClient.js.map +1 -1
  21. package/dist/components/AgentsPicker/index.d.ts.map +1 -1
  22. package/dist/components/AgentsPicker/index.js +3 -2
  23. package/dist/components/AgentsPicker/index.js.map +1 -1
  24. package/dist/components/ChangePassword/index.d.ts.map +1 -1
  25. package/dist/components/ChangePassword/index.js +3 -2
  26. package/dist/components/ChangePassword/index.js.map +1 -1
  27. package/dist/components/ChatShell/ShellHeader.d.ts.map +1 -1
  28. package/dist/components/ChatShell/ShellHeader.js +4 -3
  29. package/dist/components/ChatShell/ShellHeader.js.map +1 -1
  30. package/dist/components/ChatShell/UserMenu.d.ts.map +1 -1
  31. package/dist/components/ChatShell/UserMenu.js +13 -1
  32. package/dist/components/ChatShell/UserMenu.js.map +1 -1
  33. package/dist/components/ChatShell/index.d.ts.map +1 -1
  34. package/dist/components/ChatShell/index.js +3 -2
  35. package/dist/components/ChatShell/index.js.map +1 -1
  36. package/dist/components/DataClubAIHub/index.d.ts +7 -1
  37. package/dist/components/DataClubAIHub/index.d.ts.map +1 -1
  38. package/dist/components/DataClubAIHub/index.js +3 -1
  39. package/dist/components/DataClubAIHub/index.js.map +1 -1
  40. package/dist/components/DataClubAIHubAdmin/SessionsOverviewPage.d.ts.map +1 -1
  41. package/dist/components/DataClubAIHubAdmin/SessionsOverviewPage.js +5 -23
  42. package/dist/components/DataClubAIHubAdmin/SessionsOverviewPage.js.map +1 -1
  43. package/dist/components/DataClubAIHubAdmin/SettingsPage.d.ts.map +1 -1
  44. package/dist/components/DataClubAIHubAdmin/SettingsPage.js +45 -16
  45. package/dist/components/DataClubAIHubAdmin/SettingsPage.js.map +1 -1
  46. package/dist/components/DataClubAIHubAdmin/index.d.ts.map +1 -1
  47. package/dist/components/DataClubAIHubAdmin/index.js +3 -2
  48. package/dist/components/DataClubAIHubAdmin/index.js.map +1 -1
  49. package/dist/components/DataClubAIHubLogin/index.d.ts.map +1 -1
  50. package/dist/components/DataClubAIHubLogin/index.js +3 -2
  51. package/dist/components/DataClubAIHubLogin/index.js.map +1 -1
  52. package/dist/components/EffortControl/index.d.ts.map +1 -1
  53. package/dist/components/EffortControl/index.js +3 -2
  54. package/dist/components/EffortControl/index.js.map +1 -1
  55. package/dist/components/LanguageSwitcher/index.d.ts +25 -0
  56. package/dist/components/LanguageSwitcher/index.d.ts.map +1 -0
  57. package/dist/components/LanguageSwitcher/index.js +73 -0
  58. package/dist/components/LanguageSwitcher/index.js.map +1 -0
  59. package/dist/components/MainColumn/index.d.ts.map +1 -1
  60. package/dist/components/MainColumn/index.js +3 -2
  61. package/dist/components/MainColumn/index.js.map +1 -1
  62. package/dist/components/MessageInput/index.d.ts.map +1 -1
  63. package/dist/components/MessageInput/index.js +3 -2
  64. package/dist/components/MessageInput/index.js.map +1 -1
  65. package/dist/components/MessageList/index.d.ts.map +1 -1
  66. package/dist/components/MessageList/index.js +3 -2
  67. package/dist/components/MessageList/index.js.map +1 -1
  68. package/dist/components/ReasoningView/index.d.ts.map +1 -1
  69. package/dist/components/ReasoningView/index.js +3 -2
  70. package/dist/components/ReasoningView/index.js.map +1 -1
  71. package/dist/components/ScrollToPresentButton/index.d.ts.map +1 -1
  72. package/dist/components/ScrollToPresentButton/index.js +3 -2
  73. package/dist/components/ScrollToPresentButton/index.js.map +1 -1
  74. package/dist/components/SessionsPanel/index.d.ts.map +1 -1
  75. package/dist/components/SessionsPanel/index.js +6 -34
  76. package/dist/components/SessionsPanel/index.js.map +1 -1
  77. package/dist/components/ThemeSwitcher/index.d.ts.map +1 -1
  78. package/dist/components/ThemeSwitcher/index.js +3 -2
  79. package/dist/components/ThemeSwitcher/index.js.map +1 -1
  80. package/dist/components/ToolCallView/index.d.ts.map +1 -1
  81. package/dist/components/ToolCallView/index.js +3 -2
  82. package/dist/components/ToolCallView/index.js.map +1 -1
  83. package/dist/components/TpmBanner/index.d.ts.map +1 -1
  84. package/dist/components/TpmBanner/index.js +3 -2
  85. package/dist/components/TpmBanner/index.js.map +1 -1
  86. package/dist/context/AIHubContext.d.ts +23 -0
  87. package/dist/context/AIHubContext.d.ts.map +1 -1
  88. package/dist/context/AIHubContext.js.map +1 -1
  89. package/dist/context/AIHubProvider.d.ts +13 -0
  90. package/dist/context/AIHubProvider.d.ts.map +1 -1
  91. package/dist/context/AIHubProvider.js +39 -2
  92. package/dist/context/AIHubProvider.js.map +1 -1
  93. package/dist/context/index.d.ts +1 -0
  94. package/dist/context/index.d.ts.map +1 -1
  95. package/dist/context/index.js +1 -0
  96. package/dist/context/index.js.map +1 -1
  97. package/dist/context/language.d.ts +44 -0
  98. package/dist/context/language.d.ts.map +1 -0
  99. package/dist/context/language.js +74 -0
  100. package/dist/context/language.js.map +1 -0
  101. package/dist/context/useAIHub.d.ts +8 -0
  102. package/dist/context/useAIHub.d.ts.map +1 -1
  103. package/dist/context/useAIHub.js +4 -0
  104. package/dist/context/useAIHub.js.map +1 -1
  105. package/dist/context/useAIHubTexts.d.ts +40 -0
  106. package/dist/context/useAIHubTexts.d.ts.map +1 -0
  107. package/dist/context/useAIHubTexts.js +52 -0
  108. package/dist/context/useAIHubTexts.js.map +1 -0
  109. package/dist/index.d.ts +4 -2
  110. package/dist/index.d.ts.map +1 -1
  111. package/dist/index.js +4 -1
  112. package/dist/index.js.map +1 -1
  113. package/dist/primitives/index.d.ts +3 -0
  114. package/dist/primitives/index.d.ts.map +1 -1
  115. package/dist/primitives/index.js +3 -0
  116. package/dist/primitives/index.js.map +1 -1
  117. package/dist/stories/_mockProvider.d.ts.map +1 -1
  118. package/dist/stories/_mockProvider.js +5 -0
  119. package/dist/stories/_mockProvider.js.map +1 -1
  120. package/dist/types.d.ts +13 -0
  121. package/dist/types.d.ts.map +1 -1
  122. package/dist/util/relativeTime.d.ts +49 -0
  123. package/dist/util/relativeTime.d.ts.map +1 -0
  124. package/dist/util/relativeTime.js +78 -0
  125. package/dist/util/relativeTime.js.map +1 -0
  126. package/package.json +1 -1
  127. package/src/auth/locales/cs.ts +452 -0
  128. package/src/auth/locales/index.ts +38 -0
  129. package/src/auth/locales/sk.ts +452 -0
  130. package/src/auth/texts.ts +45 -6
  131. package/src/client/AIHubClient.ts +22 -0
  132. package/src/components/AgentsPicker/index.tsx +3 -2
  133. package/src/components/ChangePassword/index.tsx +2 -2
  134. package/src/components/ChatShell/ShellHeader.tsx +2 -0
  135. package/src/components/ChatShell/UserMenu.tsx +14 -1
  136. package/src/components/ChatShell/index.tsx +2 -2
  137. package/src/components/DataClubAIHub/index.tsx +11 -1
  138. package/src/components/DataClubAIHubAdmin/SessionsOverviewPage.tsx +5 -19
  139. package/src/components/DataClubAIHubAdmin/SettingsPage.tsx +138 -13
  140. package/src/components/DataClubAIHubAdmin/index.tsx +2 -2
  141. package/src/components/DataClubAIHubLogin/index.tsx +3 -2
  142. package/src/components/EffortControl/index.tsx +2 -2
  143. package/src/components/LanguageSwitcher/index.tsx +127 -0
  144. package/src/components/MainColumn/index.tsx +2 -2
  145. package/src/components/MessageInput/index.tsx +2 -2
  146. package/src/components/MessageList/index.tsx +2 -2
  147. package/src/components/ReasoningView/index.tsx +2 -2
  148. package/src/components/ScrollToPresentButton/index.tsx +2 -2
  149. package/src/components/SessionsPanel/index.tsx +5 -27
  150. package/src/components/ThemeSwitcher/index.tsx +3 -2
  151. package/src/components/ToolCallView/index.tsx +2 -2
  152. package/src/components/TpmBanner/index.tsx +2 -2
  153. package/src/context/AIHubContext.ts +25 -0
  154. package/src/context/AIHubProvider.tsx +68 -1
  155. package/src/context/index.ts +1 -0
  156. package/src/context/language.ts +81 -0
  157. package/src/context/useAIHub.ts +12 -0
  158. package/src/context/useAIHubTexts.ts +64 -0
  159. package/src/index.ts +13 -0
  160. package/src/primitives/index.ts +3 -0
  161. package/src/stories/_mockProvider.tsx +5 -0
  162. package/src/styles/default.css +79 -0
  163. package/src/types.ts +14 -0
  164. package/src/util/relativeTime.ts +90 -0
@@ -84,7 +84,20 @@ export function UserMenu(props: UserMenuProps): ReactNode {
84
84
  if (!open) return;
85
85
  function handleClick(e: MouseEvent): void {
86
86
  const node = wrapRef.current;
87
- if (node && !node.contains(e.target as Node)) {
87
+ if (!node) return;
88
+ // Shadow-DOM safety: when this component is packaged as a Web Component
89
+ // (`defineWebComponent`), events that bubble to `document` are
90
+ // *retargeted* so `e.target` becomes the host element, never the clicked
91
+ // node inside the shadow root. `node.contains(e.target)` would then be
92
+ // false for clicks on the button itself, so a second click could never
93
+ // toggle the menu closed. `composedPath()` crosses the shadow boundary
94
+ // and lists the real nodes the event traversed; fall back to `e.target`
95
+ // where it isn't available.
96
+ const path =
97
+ typeof e.composedPath === "function" ? e.composedPath() : [];
98
+ const clickedInside =
99
+ path.length > 0 ? path.includes(node) : node.contains(e.target as Node);
100
+ if (!clickedInside) {
88
101
  setOpen(false);
89
102
  }
90
103
  }
@@ -16,10 +16,10 @@
16
16
  import { useEffect, useMemo, type ReactNode } from "react";
17
17
 
18
18
  import {
19
- resolveAIHubTexts,
20
19
  type AIHubTexts,
21
20
  type DeepPartial,
22
21
  } from "../../auth/texts.js";
22
+ import { useResolvedTexts } from "../../context/useAIHubTexts.js";
23
23
  import { useAIHub } from "../../context/useAIHub.js";
24
24
  import {
25
25
  resolveAffordanceFlags,
@@ -171,7 +171,7 @@ export function ChatShell(props: ChatShellProps): ReactNode {
171
171
  setActiveSession,
172
172
  featureToggles,
173
173
  } = useAIHub();
174
- const texts = resolveAIHubTexts(props.texts);
174
+ const texts = useResolvedTexts(props.texts);
175
175
  const productName = props.productName ?? "ai-hub";
176
176
  const showAgentsPicker = props.showAgentsPicker ?? true;
177
177
  const sessionsPanelPosition = props.sessionsPanelPosition ?? "left";
@@ -40,7 +40,7 @@ import type {
40
40
  import type {
41
41
  AIHubChangePasswordClassNames,
42
42
  } from "../ChangePassword/index.js";
43
- import type { AIHubTexts, DeepPartial } from "../../auth/texts.js";
43
+ import type { AIHubLanguage, AIHubTexts, DeepPartial } from "../../auth/texts.js";
44
44
 
45
45
  /**
46
46
  * Props for `<DataClubAIHub />`.
@@ -66,6 +66,12 @@ export interface DataClubAIHubProps {
66
66
  usernamePlaceholder?: string;
67
67
  /** Wrapper-supplied i18n. */
68
68
  texts?: DeepPartial<AIHubTexts>;
69
+ /**
70
+ * Fallback UI language before `/auth/me` resolves and when no per-device
71
+ * choice is cached. The server's `language_default` wins after hydration.
72
+ * Built-in locales: `"en"` | `"cs"` | `"sk"`. Defaults to `"en"`.
73
+ */
74
+ defaultLanguage?: AIHubLanguage;
69
75
  /** Per-element class overrides for the login screen. */
70
76
  loginClassNames?: Partial<AIHubLoginClassNames>;
71
77
  /** Per-element class overrides for the change-password screen. */
@@ -161,6 +167,10 @@ export function DataClubAIHub(props: DataClubAIHubProps): ReactNode {
161
167
  <AIHubProvider
162
168
  baseUrl={baseUrl}
163
169
  {...(props.defaultTheme !== undefined ? { defaultTheme: props.defaultTheme } : {})}
170
+ {...(props.defaultLanguage !== undefined
171
+ ? { defaultLanguage: props.defaultLanguage }
172
+ : {})}
173
+ {...(props.texts !== undefined ? { texts: props.texts } : {})}
164
174
  >
165
175
  <AuthGate
166
176
  {...(props.productName !== undefined ? { productName: props.productName } : {})}
@@ -33,6 +33,7 @@ import type {
33
33
  } from "../../client/AIHubClient.js";
34
34
  import { useAIHub } from "../../context/useAIHub.js";
35
35
  import { useAIHubAuth } from "../../context/useAIHubAuth.js";
36
+ import { formatRelativeTime } from "../../util/relativeTime.js";
36
37
  import type { AIHubMessage } from "../../types.js";
37
38
  import { ReasoningView } from "../ReasoningView/index.js";
38
39
  import { ToolCallView, type ToolCallStatus } from "../ToolCallView/index.js";
@@ -48,21 +49,6 @@ type StatusFilter = "active" | "archived" | "all";
48
49
  /** Page size — matches the spec's default of 50. */
49
50
  const PAGE_SIZE = 50;
50
51
 
51
- /** Format an ISO date as a short relative "x ago" string. */
52
- function formatLastMessage(iso: string): string {
53
- const date = Date.parse(iso);
54
- if (Number.isNaN(date)) return iso;
55
- const diffMs = Date.now() - date;
56
- if (diffMs < 60_000) return "just now";
57
- const minutes = Math.floor(diffMs / 60_000);
58
- if (minutes < 60) return `${minutes} min ago`;
59
- const hours = Math.floor(minutes / 60);
60
- if (hours < 24) return `${hours}h ago`;
61
- const days = Math.floor(hours / 24);
62
- if (days < 7) return `${days}d ago`;
63
- return new Date(date).toISOString().slice(0, 10);
64
- }
65
-
66
52
  /** Props for `<AdminSessionsOverviewPage />`. */
67
53
  export interface AdminSessionsOverviewPageProps {
68
54
  /** Resolved admin texts. */
@@ -78,7 +64,7 @@ export interface AdminSessionsOverviewPageProps {
78
64
  export function AdminSessionsOverviewPage(
79
65
  props: AdminSessionsOverviewPageProps,
80
66
  ): ReactNode {
81
- const { client } = useAIHub();
67
+ const { client, language } = useAIHub();
82
68
 
83
69
  const [view, setView] = useState<View>({ kind: "list" });
84
70
 
@@ -337,7 +323,7 @@ export function AdminSessionsOverviewPage(
337
323
  <td>{s.title}</td>
338
324
  <td>{s.owner_username}</td>
339
325
  <td>{s.agent_display_name}</td>
340
- <td>{formatLastMessage(s.updated_at)}</td>
326
+ <td>{formatRelativeTime(s.updated_at, language, { absoluteAfterDays: 7 })}</td>
341
327
  <td>
342
328
  <button
343
329
  type="button"
@@ -407,7 +393,7 @@ interface AdminSessionDetailProps {
407
393
  * detail browses arbitrary sessions outside the chat context.
408
394
  */
409
395
  function AdminSessionDetail(props: AdminSessionDetailProps): ReactNode {
410
- const { client } = useAIHub();
396
+ const { client, language } = useAIHub();
411
397
  const auth = useAIHubAuth();
412
398
  const isSuperAdmin = auth.user?.role === "super_admin";
413
399
  const { session, texts } = props;
@@ -552,7 +538,7 @@ function AdminSessionDetail(props: AdminSessionDetailProps): ReactNode {
552
538
  >
553
539
  <div className="data-club-ai-hub-admin__readonly-meta">
554
540
  <strong>{m.role}</strong>
555
- <span>{formatLastMessage(m.created_at)}</span>
541
+ <span>{formatRelativeTime(m.created_at, language, { absoluteAfterDays: 7 })}</span>
556
542
  </div>
557
543
  <div className="data-club-ai-hub-admin__readonly-content">
558
544
  {m.content}
@@ -26,10 +26,14 @@
26
26
  */
27
27
  import { useEffect, useState, type ReactNode } from "react";
28
28
 
29
- import type { AIHubAdminTexts } from "../../auth/texts.js";
29
+ import type { AIHubAdminTexts, AIHubLanguage } from "../../auth/texts.js";
30
30
  import type { AdminSettingsView } from "../../client/AIHubClient.js";
31
31
  import { useAIHub } from "../../context/useAIHub.js";
32
32
  import { useAIHubAuth } from "../../context/useAIHubAuth.js";
33
+ import {
34
+ formatAbsoluteDate,
35
+ formatRelativeDay,
36
+ } from "../../util/relativeTime.js";
33
37
  import type { AIHubFeatureToggles } from "../../types.js";
34
38
 
35
39
  /** All feature-toggle keys per §8.4. */
@@ -86,16 +90,14 @@ function defaultToggleValue(): boolean {
86
90
  return true;
87
91
  }
88
92
 
89
- /** Format ISO date as `YYYY-MM-DD (N days ago)`. */
90
- function formatRotationStamp(iso: string | null): string | null {
93
+ /** Format ISO date as `<localized date> (<localized N days ago>)`. */
94
+ function formatRotationStamp(
95
+ iso: string | null,
96
+ language: AIHubLanguage,
97
+ ): string | null {
91
98
  if (iso === null) return null;
92
- const ts = Date.parse(iso);
93
- if (Number.isNaN(ts)) return iso;
94
- const dateOnly = new Date(ts).toISOString().slice(0, 10);
95
- const diffDays = Math.floor((Date.now() - ts) / (24 * 60 * 60 * 1000));
96
- if (diffDays <= 0) return `${dateOnly} (today)`;
97
- if (diffDays === 1) return `${dateOnly} (1 day ago)`;
98
- return `${dateOnly} (${diffDays} days ago)`;
99
+ if (Number.isNaN(Date.parse(iso))) return iso;
100
+ return `${formatAbsoluteDate(iso, language)} (${formatRelativeDay(iso, language)})`;
99
101
  }
100
102
 
101
103
  /** Props for `<AdminSettingsPage />`. */
@@ -181,6 +183,11 @@ export function AdminSettingsPage(props: AdminSettingsPageProps): ReactNode {
181
183
  texts={props.texts}
182
184
  onSaved={handleSectionSaved}
183
185
  />
186
+ <LanguageSection
187
+ settings={settings}
188
+ texts={props.texts}
189
+ onSaved={handleSectionSaved}
190
+ />
184
191
  <FeatureTogglesSection
185
192
  settings={settings}
186
193
  texts={props.texts}
@@ -525,6 +532,120 @@ function ThemeSection(props: ThemeSectionProps): ReactNode {
525
532
  );
526
533
  }
527
534
 
535
+ /* ────────────────────────────────────────────────────────────────────────── */
536
+ /* Section: Language */
537
+ /* ────────────────────────────────────────────────────────────────────────── */
538
+
539
+ interface LanguageSectionProps extends SectionProps {
540
+ onSaved: (next: AdminSettingsView) => void;
541
+ }
542
+
543
+ function LanguageSection(props: LanguageSectionProps): ReactNode {
544
+ const { client } = useAIHub();
545
+ const [languageDefault, setLanguageDefault] = useState<AIHubLanguage>(
546
+ props.settings.language.default,
547
+ );
548
+ const [userLocked, setUserLocked] = useState(
549
+ props.settings.language.user_locked,
550
+ );
551
+ const [saving, setSaving] = useState(false);
552
+ const [flash, setFlash] = useState<{ tone: "success" | "error"; text: string } | null>(
553
+ null,
554
+ );
555
+
556
+ async function handleSave(event: React.FormEvent): Promise<void> {
557
+ event.preventDefault();
558
+ setSaving(true);
559
+ setFlash(null);
560
+ try {
561
+ const next = await client.patchAdminSettings({
562
+ language: { default: languageDefault, userLocked },
563
+ });
564
+ props.onSaved(next);
565
+ setFlash({ tone: "success", text: props.texts.settingsSaveSuccessFlash });
566
+ } catch (err) {
567
+ setFlash({
568
+ tone: "error",
569
+ text:
570
+ err instanceof Error && err.message.length > 0
571
+ ? err.message
572
+ : props.texts.settingsSaveGenericError,
573
+ });
574
+ } finally {
575
+ setSaving(false);
576
+ }
577
+ }
578
+
579
+ return (
580
+ <section
581
+ className="data-club-ai-hub-admin__settings-section"
582
+ data-testid="admin-settings-language-section"
583
+ >
584
+ <h2 className="data-club-ai-hub-admin__settings-section-heading">
585
+ {props.texts.settingsLanguageSectionHeading}
586
+ </h2>
587
+ <form onSubmit={(event) => void handleSave(event)} noValidate>
588
+ <label
589
+ className="data-club-ai-hub-admin__form-label"
590
+ htmlFor="admin-settings-language-default"
591
+ >
592
+ {props.texts.settingsLanguageDefaultLabel}
593
+ </label>
594
+ <select
595
+ id="admin-settings-language-default"
596
+ className="data-club-ai-hub-admin__form-input"
597
+ data-testid="admin-settings-language-default"
598
+ value={languageDefault}
599
+ onChange={(event) =>
600
+ setLanguageDefault(event.target.value as AIHubLanguage)
601
+ }
602
+ disabled={saving}
603
+ >
604
+ <option value="en">{props.texts.settingsLanguageDefaultEnglish}</option>
605
+ <option value="cs">{props.texts.settingsLanguageDefaultCzech}</option>
606
+ <option value="sk">{props.texts.settingsLanguageDefaultSlovak}</option>
607
+ </select>
608
+ <label className="data-club-ai-hub-admin__checkbox">
609
+ <input
610
+ type="checkbox"
611
+ data-testid="admin-settings-language-locked"
612
+ checked={userLocked}
613
+ onChange={(event) => setUserLocked(event.target.checked)}
614
+ disabled={saving}
615
+ />
616
+ <span>{props.texts.settingsLanguageLockedLabel}</span>
617
+ </label>
618
+ <p className="data-club-ai-hub-admin__form-helper">
619
+ {props.texts.settingsLanguageLockedHelper}
620
+ </p>
621
+ {flash !== null ? (
622
+ <div
623
+ className={
624
+ flash.tone === "success"
625
+ ? "data-club-ai-hub-admin__flash data-club-ai-hub-admin__flash--success"
626
+ : "data-club-ai-hub-admin__flash data-club-ai-hub-admin__flash--error"
627
+ }
628
+ data-testid="admin-settings-language-flash"
629
+ role={flash.tone === "error" ? "alert" : "status"}
630
+ >
631
+ {flash.text}
632
+ </div>
633
+ ) : null}
634
+ <button
635
+ type="submit"
636
+ className="data-club-ai-hub-admin__button data-club-ai-hub-admin__button--primary"
637
+ data-testid="admin-settings-language-save"
638
+ disabled={saving}
639
+ >
640
+ {saving
641
+ ? props.texts.settingsSaveSavingButton
642
+ : props.texts.settingsSaveLanguageButton}
643
+ </button>
644
+ </form>
645
+ </section>
646
+ );
647
+ }
648
+
528
649
  /* ────────────────────────────────────────────────────────────────────────── */
529
650
  /* Section: Feature toggles */
530
651
  /* ────────────────────────────────────────────────────────────────────────── */
@@ -649,10 +770,13 @@ interface SigningKeySectionProps extends SectionProps {
649
770
  }
650
771
 
651
772
  function SigningKeySection(props: SigningKeySectionProps): ReactNode {
652
- const { client } = useAIHub();
773
+ const { client, language } = useAIHub();
653
774
  const auth = useAIHubAuth();
654
775
  const [showModal, setShowModal] = useState(false);
655
- const lastRotated = formatRotationStamp(props.settings.signing_key_rotated_at);
776
+ const lastRotated = formatRotationStamp(
777
+ props.settings.signing_key_rotated_at,
778
+ language,
779
+ );
656
780
 
657
781
  async function handleRotateConfirmed(): Promise<void> {
658
782
  await client.rotateAdminSigningKey();
@@ -716,11 +840,12 @@ interface MetricsTokenSectionProps extends SectionProps {
716
840
  }
717
841
 
718
842
  function MetricsTokenSection(props: MetricsTokenSectionProps): ReactNode {
719
- const { client } = useAIHub();
843
+ const { client, language } = useAIHub();
720
844
  const [showConfirm, setShowConfirm] = useState(false);
721
845
  const [revealedToken, setRevealedToken] = useState<string | null>(null);
722
846
  const lastRotated = formatRotationStamp(
723
847
  props.settings.metrics_scrape_token_rotated_at,
848
+ language,
724
849
  );
725
850
 
726
851
  async function handleRotateConfirmed(): Promise<void> {
@@ -25,10 +25,10 @@
25
25
  import { useMemo, useState, type ReactNode } from "react";
26
26
 
27
27
  import {
28
- resolveAIHubTexts,
29
28
  type AIHubTexts,
30
29
  type DeepPartial,
31
30
  } from "../../auth/texts.js";
31
+ import { useResolvedTexts } from "../../context/useAIHubTexts.js";
32
32
  import { useAIHub } from "../../context/useAIHub.js";
33
33
  import { useAIHubAuth } from "../../context/useAIHubAuth.js";
34
34
  import type { AIHubRole } from "../../types.js";
@@ -150,7 +150,7 @@ export function DataClubAIHubAdmin(
150
150
  // custom-property values and overrides the dark cascade from the
151
151
  // provider's outer wrapper.
152
152
  const { theme } = useAIHub();
153
- const texts = resolveAIHubTexts(props.texts);
153
+ const texts = useResolvedTexts(props.texts);
154
154
  const navItems = navItemsForRole(auth.role);
155
155
  const productName = props.productName ?? "ai-hub";
156
156
 
@@ -29,7 +29,8 @@ import { useState, type FormEvent, type ReactNode } from "react";
29
29
 
30
30
  import { useAIHubAuth } from "../../context/useAIHubAuth.js";
31
31
  import { AIHubRequestError } from "../../client/AIHubClient.js";
32
- import { resolveAIHubTexts, type AIHubTexts, type DeepPartial } from "../../auth/texts.js";
32
+ import { type AIHubTexts, type DeepPartial } from "../../auth/texts.js";
33
+ import { useResolvedTexts } from "../../context/useAIHubTexts.js";
33
34
  import type { AIHubUser } from "../../types.js";
34
35
  import { PasswordInput } from "../internal/PasswordInput.js";
35
36
 
@@ -142,7 +143,7 @@ export interface DataClubAIHubLoginProps {
142
143
  export function DataClubAIHubLogin(props: DataClubAIHubLoginProps): ReactNode {
143
144
  const auth = useAIHubAuth();
144
145
  const classes = { ...DEFAULT_CLASS_NAMES, ...(props.classNames ?? {}) };
145
- const texts = resolveAIHubTexts(props.texts).login;
146
+ const texts = useResolvedTexts(props.texts).login;
146
147
  const productName = props.productName ?? "ai-hub";
147
148
 
148
149
  const [username, setUsername] = useState("");
@@ -22,10 +22,10 @@
22
22
  import { type ChangeEvent, type ReactNode } from "react";
23
23
 
24
24
  import {
25
- resolveAIHubTexts,
26
25
  type AIHubTexts,
27
26
  type DeepPartial,
28
27
  } from "../../auth/texts.js";
28
+ import { useResolvedTexts } from "../../context/useAIHubTexts.js";
29
29
  import type { AIHubEffort } from "../../client/AIHubClient.js";
30
30
  import { useAIHub } from "../../context/useAIHub.js";
31
31
 
@@ -106,7 +106,7 @@ export interface EffortControlProps {
106
106
  */
107
107
  export function EffortControl(props: EffortControlProps): ReactNode {
108
108
  const { effort, setEffort } = useAIHub();
109
- const texts = resolveAIHubTexts(props.texts);
109
+ const texts = useResolvedTexts(props.texts);
110
110
  const cls = { ...DEFAULT_CLASS_NAMES, ...(props.classNames ?? {}) };
111
111
  const rootClass = props.className
112
112
  ? `${cls.root} ${props.className}`
@@ -0,0 +1,127 @@
1
+ /**
2
+ * `<LanguageSwitcher />` — globe button in the shell header (beside the user
3
+ * avatar) that switches the active UI language via `setLanguage` from
4
+ * `useAIHub()`.
5
+ *
6
+ * Renders **nothing** when `languageSwitcherEnabled === false` (the super-
7
+ * admin has locked the language for this deployment) — absent from the DOM
8
+ * entirely, like `<ThemeSwitcher />`. So a caller can leave it in place
9
+ * unconditionally and the primitive self-suppresses.
10
+ *
11
+ * Switching is a device-local preference (cached in `localStorage`), no
12
+ * server round-trip — picking a language flips it immediately and the whole
13
+ * tree re-renders with the new locale.
14
+ *
15
+ * Exported under `AIHub.LanguageSwitcher`.
16
+ */
17
+
18
+ import { useEffect, useRef, useState, type ReactNode } from "react";
19
+
20
+ import { AIHUB_LANGUAGE_OPTIONS } from "../../auth/locales/index.js";
21
+ import { useAIHub } from "../../context/useAIHub.js";
22
+ import { useAIHubTexts } from "../../context/useAIHubTexts.js";
23
+
24
+ /**
25
+ * Renders the language switcher (globe button + popover), or `null` when the
26
+ * deployment has locked the language.
27
+ *
28
+ * @returns the rendered switcher, or `null` when disabled by config.
29
+ */
30
+ export function LanguageSwitcher(): ReactNode {
31
+ const { language, setLanguage, languageSwitcherEnabled } = useAIHub();
32
+ const texts = useAIHubTexts();
33
+ const [open, setOpen] = useState(false);
34
+ const wrapRef = useRef<HTMLDivElement | null>(null);
35
+
36
+ // Close on outside-click + Escape. `composedPath()` crosses the shadow
37
+ // boundary so this works when packaged as a Web Component (see UserMenu for
38
+ // the full rationale).
39
+ useEffect(() => {
40
+ if (!open) return;
41
+ function handleClick(e: MouseEvent): void {
42
+ const node = wrapRef.current;
43
+ if (!node) return;
44
+ const path =
45
+ typeof e.composedPath === "function" ? e.composedPath() : [];
46
+ const clickedInside =
47
+ path.length > 0 ? path.includes(node) : node.contains(e.target as Node);
48
+ if (!clickedInside) setOpen(false);
49
+ }
50
+ function handleKey(e: KeyboardEvent): void {
51
+ if (e.key === "Escape") setOpen(false);
52
+ }
53
+ document.addEventListener("mousedown", handleClick);
54
+ document.addEventListener("keydown", handleKey);
55
+ return () => {
56
+ document.removeEventListener("mousedown", handleClick);
57
+ document.removeEventListener("keydown", handleKey);
58
+ };
59
+ }, [open]);
60
+
61
+ if (!languageSwitcherEnabled) return null;
62
+
63
+ const activeLabel =
64
+ AIHUB_LANGUAGE_OPTIONS.find((o) => o.code === language)?.label ?? language;
65
+
66
+ return (
67
+ <div
68
+ className="data-club-ai-hub__language-switcher"
69
+ ref={wrapRef}
70
+ data-testid="language-switcher"
71
+ >
72
+ <button
73
+ type="button"
74
+ className="data-club-ai-hub__language-switcher__button"
75
+ aria-haspopup="menu"
76
+ aria-expanded={open}
77
+ aria-label={texts.chat.languageSwitcherAriaLabel}
78
+ title={texts.chat.languageSwitcherAriaLabel}
79
+ onClick={() => setOpen((o) => !o)}
80
+ data-testid="language-switcher-button"
81
+ >
82
+ <span
83
+ className="data-club-ai-hub__language-switcher__icon"
84
+ aria-hidden="true"
85
+ >
86
+ 🌐
87
+ </span>
88
+ <span
89
+ className="data-club-ai-hub__language-switcher__code"
90
+ data-testid="language-switcher-code"
91
+ >
92
+ {language.toUpperCase()}
93
+ </span>
94
+ </button>
95
+ {open ? (
96
+ <div
97
+ className="data-club-ai-hub__language-switcher__dropdown"
98
+ role="menu"
99
+ data-testid="language-switcher-dropdown"
100
+ >
101
+ {AIHUB_LANGUAGE_OPTIONS.map((opt) => {
102
+ const isActive = opt.code === language;
103
+ const itemClass = isActive
104
+ ? "data-club-ai-hub__language-switcher__item data-club-ai-hub__language-switcher__item--active"
105
+ : "data-club-ai-hub__language-switcher__item";
106
+ return (
107
+ <button
108
+ key={opt.code}
109
+ type="button"
110
+ role="menuitemradio"
111
+ aria-checked={isActive}
112
+ className={itemClass}
113
+ data-testid={`language-switcher-option-${opt.code}`}
114
+ onClick={() => {
115
+ setLanguage(opt.code);
116
+ setOpen(false);
117
+ }}
118
+ >
119
+ {opt.label}
120
+ </button>
121
+ );
122
+ })}
123
+ </div>
124
+ ) : null}
125
+ </div>
126
+ );
127
+ }
@@ -17,10 +17,10 @@
17
17
  import { useMemo, type ReactNode } from "react";
18
18
 
19
19
  import {
20
- resolveAIHubTexts,
21
20
  type AIHubTexts,
22
21
  type DeepPartial,
23
22
  } from "../../auth/texts.js";
23
+ import { useResolvedTexts } from "../../context/useAIHubTexts.js";
24
24
  import { useAIHub } from "../../context/useAIHub.js";
25
25
 
26
26
  import { TurnStatusIndicator } from "../TurnStatusIndicator/index.js";
@@ -88,7 +88,7 @@ export interface MainColumnProps {
88
88
  */
89
89
  export function MainColumn(props: MainColumnProps): ReactNode {
90
90
  const { agents, activeAgentId, setActiveAgent } = useAIHub();
91
- const texts = resolveAIHubTexts(props.texts);
91
+ const texts = useResolvedTexts(props.texts);
92
92
  const cls = { ...DEFAULT_CLASS_NAMES, ...(props.classNames ?? {}) };
93
93
  const showBack = props.showBackToAgents ?? true;
94
94
  const showTurnStatus = props.showTurnStatus ?? true;
@@ -32,10 +32,10 @@ import {
32
32
  } from "react";
33
33
 
34
34
  import {
35
- resolveAIHubTexts,
36
35
  type AIHubTexts,
37
36
  type DeepPartial,
38
37
  } from "../../auth/texts.js";
38
+ import { useResolvedTexts } from "../../context/useAIHubTexts.js";
39
39
  import { useAIHub } from "../../context/useAIHub.js";
40
40
  import { chatAffordances } from "../../state/affordances.js";
41
41
 
@@ -124,7 +124,7 @@ export function MessageInput(props: MessageInputProps): ReactNode {
124
124
  pendingDraft,
125
125
  consumePendingDraft,
126
126
  } = useAIHub();
127
- const texts = resolveAIHubTexts(props.texts);
127
+ const texts = useResolvedTexts(props.texts);
128
128
  const cls = { ...DEFAULT_CLASS_NAMES, ...(props.classNames ?? {}) };
129
129
  const showToolbar = props.showToolbar ?? true;
130
130
  const showEffortControl = props.showEffortControl ?? true;
@@ -42,10 +42,10 @@ import {
42
42
  } from "react";
43
43
 
44
44
  import {
45
- resolveAIHubTexts,
46
45
  type AIHubTexts,
47
46
  type DeepPartial,
48
47
  } from "../../auth/texts.js";
48
+ import { useResolvedTexts } from "../../context/useAIHubTexts.js";
49
49
  import { useAIHub } from "../../context/useAIHub.js";
50
50
  import { chatAffordances } from "../../state/affordances.js";
51
51
  import type {
@@ -253,7 +253,7 @@ export function MessageList(props: MessageListProps): ReactNode {
253
253
  state,
254
254
  sendMessage,
255
255
  } = useAIHub();
256
- const texts = resolveAIHubTexts(props.texts);
256
+ const texts = useResolvedTexts(props.texts);
257
257
  const cls = { ...DEFAULT_CLASS_NAMES, ...(props.classNames ?? {}) };
258
258
  const showSystemMessages = props.showSystemMessages ?? true;
259
259
  const showTpmNotice = props.showTpmNotice ?? true;
@@ -25,10 +25,10 @@
25
25
  import { useState, type ReactNode } from "react";
26
26
 
27
27
  import {
28
- resolveAIHubTexts,
29
28
  type AIHubTexts,
30
29
  type DeepPartial,
31
30
  } from "../../auth/texts.js";
31
+ import { useResolvedTexts } from "../../context/useAIHubTexts.js";
32
32
 
33
33
  /** BEM class names emitted by `<ReasoningView />`. */
34
34
  export interface ReasoningViewClassNames {
@@ -110,7 +110,7 @@ export interface ReasoningViewProps {
110
110
  * @returns the rendered block.
111
111
  */
112
112
  export function ReasoningView(props: ReasoningViewProps): ReactNode {
113
- const texts = resolveAIHubTexts(props.texts);
113
+ const texts = useResolvedTexts(props.texts);
114
114
  const cls = { ...DEFAULT_CLASS_NAMES, ...(props.classNames ?? {}) };
115
115
  const [uncontrolledExpanded, setUncontrolledExpanded] = useState(
116
116
  props.defaultExpanded ?? false,
@@ -23,10 +23,10 @@
23
23
  import { type ReactNode } from "react";
24
24
 
25
25
  import {
26
- resolveAIHubTexts,
27
26
  type AIHubTexts,
28
27
  type DeepPartial,
29
28
  } from "../../auth/texts.js";
29
+ import { useResolvedTexts } from "../../context/useAIHubTexts.js";
30
30
 
31
31
  /** BEM class names emitted by `<ScrollToPresentButton />`. */
32
32
  export interface ScrollToPresentButtonClassNames {
@@ -67,7 +67,7 @@ export function ScrollToPresentButton(
67
67
  props: ScrollToPresentButtonProps,
68
68
  ): ReactNode {
69
69
  if (!props.visible) return null;
70
- const texts = resolveAIHubTexts(props.texts);
70
+ const texts = useResolvedTexts(props.texts);
71
71
  const cls = { ...DEFAULT_CLASS_NAMES, ...(props.classNames ?? {}) };
72
72
  const rootClass = props.className
73
73
  ? `${cls.root} ${props.className}`