@data-club/ai-hub 0.0.2 → 0.0.4

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 (169) 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 +404 -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 +404 -0
  12. package/dist/auth/locales/sk.js.map +1 -0
  13. package/dist/auth/texts.d.ts +42 -2
  14. package/dist/auth/texts.d.ts.map +1 -1
  15. package/dist/auth/texts.js +30 -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/index.d.ts.map +1 -1
  31. package/dist/components/ChatShell/index.js +3 -2
  32. package/dist/components/ChatShell/index.js.map +1 -1
  33. package/dist/components/DataClubAIHub/index.d.ts +7 -1
  34. package/dist/components/DataClubAIHub/index.d.ts.map +1 -1
  35. package/dist/components/DataClubAIHub/index.js +3 -1
  36. package/dist/components/DataClubAIHub/index.js.map +1 -1
  37. package/dist/components/DataClubAIHubAdmin/SessionsOverviewPage.d.ts.map +1 -1
  38. package/dist/components/DataClubAIHubAdmin/SessionsOverviewPage.js +5 -23
  39. package/dist/components/DataClubAIHubAdmin/SessionsOverviewPage.js.map +1 -1
  40. package/dist/components/DataClubAIHubAdmin/SettingsPage.d.ts.map +1 -1
  41. package/dist/components/DataClubAIHubAdmin/SettingsPage.js +45 -16
  42. package/dist/components/DataClubAIHubAdmin/SettingsPage.js.map +1 -1
  43. package/dist/components/DataClubAIHubAdmin/index.d.ts.map +1 -1
  44. package/dist/components/DataClubAIHubAdmin/index.js +3 -2
  45. package/dist/components/DataClubAIHubAdmin/index.js.map +1 -1
  46. package/dist/components/DataClubAIHubLogin/index.d.ts.map +1 -1
  47. package/dist/components/DataClubAIHubLogin/index.js +3 -2
  48. package/dist/components/DataClubAIHubLogin/index.js.map +1 -1
  49. package/dist/components/EffortControl/index.d.ts.map +1 -1
  50. package/dist/components/EffortControl/index.js +3 -2
  51. package/dist/components/EffortControl/index.js.map +1 -1
  52. package/dist/components/LanguageSwitcher/index.d.ts +25 -0
  53. package/dist/components/LanguageSwitcher/index.d.ts.map +1 -0
  54. package/dist/components/LanguageSwitcher/index.js +73 -0
  55. package/dist/components/LanguageSwitcher/index.js.map +1 -0
  56. package/dist/components/MainColumn/index.d.ts.map +1 -1
  57. package/dist/components/MainColumn/index.js +3 -2
  58. package/dist/components/MainColumn/index.js.map +1 -1
  59. package/dist/components/MessageInput/index.d.ts.map +1 -1
  60. package/dist/components/MessageInput/index.js +3 -2
  61. package/dist/components/MessageInput/index.js.map +1 -1
  62. package/dist/components/MessageList/index.d.ts.map +1 -1
  63. package/dist/components/MessageList/index.js +3 -2
  64. package/dist/components/MessageList/index.js.map +1 -1
  65. package/dist/components/ReasoningView/index.d.ts.map +1 -1
  66. package/dist/components/ReasoningView/index.js +3 -2
  67. package/dist/components/ReasoningView/index.js.map +1 -1
  68. package/dist/components/ScrollToPresentButton/index.d.ts.map +1 -1
  69. package/dist/components/ScrollToPresentButton/index.js +3 -2
  70. package/dist/components/ScrollToPresentButton/index.js.map +1 -1
  71. package/dist/components/SessionsPanel/index.d.ts.map +1 -1
  72. package/dist/components/SessionsPanel/index.js +6 -34
  73. package/dist/components/SessionsPanel/index.js.map +1 -1
  74. package/dist/components/ThemeSwitcher/index.d.ts.map +1 -1
  75. package/dist/components/ThemeSwitcher/index.js +3 -2
  76. package/dist/components/ThemeSwitcher/index.js.map +1 -1
  77. package/dist/components/ToolCallView/index.d.ts.map +1 -1
  78. package/dist/components/ToolCallView/index.js +3 -2
  79. package/dist/components/ToolCallView/index.js.map +1 -1
  80. package/dist/components/TpmBanner/index.d.ts.map +1 -1
  81. package/dist/components/TpmBanner/index.js +3 -2
  82. package/dist/components/TpmBanner/index.js.map +1 -1
  83. package/dist/components/TurnStatusIndicator/index.d.ts.map +1 -1
  84. package/dist/components/TurnStatusIndicator/index.js +2 -1
  85. package/dist/components/TurnStatusIndicator/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/state/affordances.d.ts +13 -1
  118. package/dist/state/affordances.d.ts.map +1 -1
  119. package/dist/state/affordances.js +11 -9
  120. package/dist/state/affordances.js.map +1 -1
  121. package/dist/stories/_mockProvider.d.ts.map +1 -1
  122. package/dist/stories/_mockProvider.js +5 -0
  123. package/dist/stories/_mockProvider.js.map +1 -1
  124. package/dist/types.d.ts +13 -0
  125. package/dist/types.d.ts.map +1 -1
  126. package/dist/util/relativeTime.d.ts +49 -0
  127. package/dist/util/relativeTime.d.ts.map +1 -0
  128. package/dist/util/relativeTime.js +78 -0
  129. package/dist/util/relativeTime.js.map +1 -0
  130. package/package.json +1 -1
  131. package/src/auth/locales/cs.ts +462 -0
  132. package/src/auth/locales/index.ts +38 -0
  133. package/src/auth/locales/sk.ts +462 -0
  134. package/src/auth/texts.ts +75 -6
  135. package/src/client/AIHubClient.ts +22 -0
  136. package/src/components/AgentsPicker/index.tsx +3 -2
  137. package/src/components/ChangePassword/index.tsx +2 -2
  138. package/src/components/ChatShell/ShellHeader.tsx +2 -0
  139. package/src/components/ChatShell/index.tsx +2 -2
  140. package/src/components/DataClubAIHub/index.tsx +11 -1
  141. package/src/components/DataClubAIHubAdmin/SessionsOverviewPage.tsx +5 -19
  142. package/src/components/DataClubAIHubAdmin/SettingsPage.tsx +138 -13
  143. package/src/components/DataClubAIHubAdmin/index.tsx +2 -2
  144. package/src/components/DataClubAIHubLogin/index.tsx +3 -2
  145. package/src/components/EffortControl/index.tsx +2 -2
  146. package/src/components/LanguageSwitcher/index.tsx +127 -0
  147. package/src/components/MainColumn/index.tsx +2 -2
  148. package/src/components/MessageInput/index.tsx +2 -2
  149. package/src/components/MessageList/index.tsx +2 -2
  150. package/src/components/ReasoningView/index.tsx +2 -2
  151. package/src/components/ScrollToPresentButton/index.tsx +2 -2
  152. package/src/components/SessionsPanel/index.tsx +5 -27
  153. package/src/components/ThemeSwitcher/index.tsx +3 -2
  154. package/src/components/ToolCallView/index.tsx +2 -2
  155. package/src/components/TpmBanner/index.tsx +2 -2
  156. package/src/components/TurnStatusIndicator/index.tsx +2 -1
  157. package/src/context/AIHubContext.ts +25 -0
  158. package/src/context/AIHubProvider.tsx +68 -1
  159. package/src/context/index.ts +1 -0
  160. package/src/context/language.ts +81 -0
  161. package/src/context/useAIHub.ts +12 -0
  162. package/src/context/useAIHubTexts.ts +64 -0
  163. package/src/index.ts +13 -0
  164. package/src/primitives/index.ts +3 -0
  165. package/src/state/affordances.ts +34 -10
  166. package/src/stories/_mockProvider.tsx +5 -0
  167. package/src/styles/default.css +79 -0
  168. package/src/types.ts +14 -0
  169. package/src/util/relativeTime.ts +90 -0
@@ -30,10 +30,11 @@
30
30
  import { useEffect, useMemo, useState, type ReactNode } from "react";
31
31
 
32
32
  import {
33
- resolveAIHubTexts,
34
33
  type AIHubTexts,
35
34
  type DeepPartial,
36
35
  } from "../../auth/texts.js";
36
+ import { useResolvedTexts } from "../../context/useAIHubTexts.js";
37
+ import { formatRelativeTime } from "../../util/relativeTime.js";
37
38
  import { useAIHub } from "../../context/useAIHub.js";
38
39
  import type { Session } from "../../types.js";
39
40
 
@@ -125,30 +126,6 @@ export interface SessionsPanelProps {
125
126
  texts?: DeepPartial<AIHubTexts>;
126
127
  }
127
128
 
128
- /**
129
- * Formats an ISO-8601 timestamp into a short "N min ago" / "yesterday" /
130
- * "N days ago" line. Intentionally minimal — i18n libraries can take this
131
- * over later via the `texts.*` map if needed.
132
- *
133
- * @param iso - ISO timestamp.
134
- * @returns the short relative-time string.
135
- */
136
- function formatRelative(iso: string): string {
137
- const then = Date.parse(iso);
138
- if (Number.isNaN(then)) return "";
139
- const diffMs = Date.now() - then;
140
- const minutes = Math.round(diffMs / 60_000);
141
- if (minutes < 1) return "just now";
142
- if (minutes === 1) return "1 min ago";
143
- if (minutes < 60) return `${minutes} min ago`;
144
- const hours = Math.round(minutes / 60);
145
- if (hours === 1) return "1 hr ago";
146
- if (hours < 24) return `${hours} hr ago`;
147
- const days = Math.round(hours / 24);
148
- if (days === 1) return "yesterday";
149
- return `${days} days ago`;
150
- }
151
-
152
129
  /**
153
130
  * Renders the sessions panel.
154
131
  *
@@ -165,8 +142,9 @@ export function SessionsPanel(props: SessionsPanelProps): ReactNode {
165
142
  patchSession,
166
143
  includeArchivedSessions,
167
144
  setIncludeArchivedSessions,
145
+ language,
168
146
  } = useAIHub();
169
- const texts = resolveAIHubTexts(props.texts);
147
+ const texts = useResolvedTexts(props.texts);
170
148
  const cls = { ...DEFAULT_CLASS_NAMES, ...(props.classNames ?? {}) };
171
149
  const showCreate = props.showCreateButton ?? true;
172
150
  const collapsible = props.collapsible ?? true;
@@ -313,7 +291,7 @@ export function SessionsPanel(props: SessionsPanelProps): ReactNode {
313
291
  >
314
292
  <span>{displayTitle}</span>
315
293
  <span className={cls.itemMeta}>
316
- {formatRelative(session.updated_at)}
294
+ {formatRelativeTime(session.updated_at, language)}
317
295
  </span>
318
296
  </button>
319
297
  <div className={cls.itemActions} data-testid="session-item-actions">
@@ -24,7 +24,8 @@
24
24
  import { type ReactNode } from "react";
25
25
 
26
26
  import type { AIHubChatTexts } from "../../auth/texts.js";
27
- import { resolveAIHubTexts, type AIHubTexts, type DeepPartial } from "../../auth/texts.js";
27
+ import { useResolvedTexts } from "../../context/useAIHubTexts.js";
28
+ import { type AIHubTexts, type DeepPartial } from "../../auth/texts.js";
28
29
  import { useAIHub } from "../../context/useAIHub.js";
29
30
 
30
31
  /**
@@ -79,7 +80,7 @@ export interface ThemeSwitcherProps {
79
80
  */
80
81
  export function ThemeSwitcher(props: ThemeSwitcherProps): ReactNode {
81
82
  const { theme, setTheme, themeSwitcherEnabled } = useAIHub();
82
- const texts = resolveAIHubTexts(props.texts);
83
+ const texts = useResolvedTexts(props.texts);
83
84
  const chatTexts: AIHubChatTexts = texts.chat;
84
85
  const cls = { ...DEFAULT_CLASS_NAMES, ...(props.classNames ?? {}) };
85
86
  const showLabel = props.showLabel ?? true;
@@ -20,10 +20,10 @@
20
20
  import { useState, type ReactNode } from "react";
21
21
 
22
22
  import {
23
- resolveAIHubTexts,
24
23
  type AIHubTexts,
25
24
  type DeepPartial,
26
25
  } from "../../auth/texts.js";
26
+ import { useResolvedTexts } from "../../context/useAIHubTexts.js";
27
27
  import { formatToolContentForDisplay } from "./formatContent.js";
28
28
 
29
29
  /** Status of a tool call. */
@@ -182,7 +182,7 @@ function statusText(
182
182
  * @returns the rendered card.
183
183
  */
184
184
  export function ToolCallView(props: ToolCallViewProps): ReactNode {
185
- const texts = resolveAIHubTexts(props.texts);
185
+ const texts = useResolvedTexts(props.texts);
186
186
  const cls = { ...DEFAULT_CLASS_NAMES, ...(props.classNames ?? {}) };
187
187
  const [uncontrolledExpanded, setUncontrolledExpanded] = useState(
188
188
  props.defaultExpanded ?? false,
@@ -26,10 +26,10 @@
26
26
  import { useEffect, useState, type ReactNode } from "react";
27
27
 
28
28
  import {
29
- resolveAIHubTexts,
30
29
  type AIHubTexts,
31
30
  type DeepPartial,
32
31
  } from "../../auth/texts.js";
32
+ import { useResolvedTexts } from "../../context/useAIHubTexts.js";
33
33
  import { useAIHub } from "../../context/useAIHub.js";
34
34
 
35
35
  import { SystemNotice } from "../SystemNotice/index.js";
@@ -90,7 +90,7 @@ export interface TpmBannerProps {
90
90
  */
91
91
  export function TpmBanner(props: TpmBannerProps): ReactNode {
92
92
  const { state, lastTurnError } = useAIHub();
93
- const texts = resolveAIHubTexts(props.texts);
93
+ const texts = useResolvedTexts(props.texts);
94
94
 
95
95
  // Tick at 1Hz while we're in the wait phase. The interval is
96
96
  // re-installed when the deadline changes (new wait) or removed when
@@ -173,6 +173,7 @@ export function TurnStatusIndicator(
173
173
  }, [state.status, state.phase]);
174
174
 
175
175
  const visual = turnStatusVisual(effective.status, effective.phase);
176
+ const label = ctx.texts.chat[visual.labelKey];
176
177
  const rootClass = props.className
177
178
  ? `${cls.root} ${props.className}`
178
179
  : cls.root;
@@ -196,7 +197,7 @@ export function TurnStatusIndicator(
196
197
  >
197
198
  <span className={dotClass} aria-hidden="true" />
198
199
  <span className={cls.label} data-testid="turn-status-label">
199
- {visual.label}
200
+ {label}
200
201
  </span>
201
202
  </div>
202
203
  );
@@ -18,6 +18,7 @@ import type {
18
18
  PatchSessionOptions,
19
19
  } from "../client/AIHubClient.js";
20
20
  import type { AIHubEffort } from "../client/AIHubClient.js";
21
+ import type { AIHubLanguage, AIHubTexts } from "../auth/texts.js";
21
22
  import type {
22
23
  AIHubAuthContext,
23
24
  AIHubFeatureToggles,
@@ -219,6 +220,30 @@ export interface AIHubContextValue {
219
220
  */
220
221
  themeSwitcherEnabled: boolean;
221
222
 
223
+ /* ── language / i18n ────────────────────────────────────────────── */
224
+ /**
225
+ * Resolved UI language (`en` | `cs` | `sk`) — computed per the same
226
+ * precedence as theme: when switching is unlocked, the user's cached
227
+ * choice wins; otherwise the deployment default. Drives `texts`.
228
+ */
229
+ language: AIHubLanguage;
230
+ /**
231
+ * Switches the UI language and caches the choice on this device. No-op
232
+ * (and the switcher is hidden) when `languageSwitcherEnabled` is false.
233
+ */
234
+ setLanguage: (next: AIHubLanguage) => void;
235
+ /**
236
+ * Mirror of `!deployment.user_language_locked`. When false, the
237
+ * `<LanguageSwitcher />` renders nothing and every user sees the default.
238
+ */
239
+ languageSwitcherEnabled: boolean;
240
+ /**
241
+ * The fully-resolved, language-aware `AIHubTexts` — the selected built-in
242
+ * locale with any wrapper `texts` override merged on top. Components read
243
+ * this via `useAIHubTexts()` so a `setLanguage` re-renders the whole tree.
244
+ */
245
+ texts: AIHubTexts;
246
+
222
247
  /* ── feature toggles (§8.4, wired in Step 10) ──────────────────── */
223
248
  /**
224
249
  * The deployment-wide UI feature toggles fetched from public
@@ -57,6 +57,14 @@ import type {
57
57
  Session,
58
58
  } from "../types.js";
59
59
 
60
+ import {
61
+ resolveAIHubTexts,
62
+ type AIHubLanguage,
63
+ type AIHubTexts,
64
+ type DeepPartial,
65
+ } from "../auth/texts.js";
66
+ import { AIHUB_LOCALES } from "../auth/locales/index.js";
67
+
60
68
  import { aiHubContext, type AIHubContextValue } from "./AIHubContext.js";
61
69
  import {
62
70
  readCachedTheme,
@@ -64,6 +72,11 @@ import {
64
72
  writeCachedTheme,
65
73
  type ResolvedTheme,
66
74
  } from "./theme.js";
75
+ import {
76
+ readCachedLanguage,
77
+ resolveLanguage,
78
+ writeCachedLanguage,
79
+ } from "./language.js";
67
80
 
68
81
  /**
69
82
  * When the post-stream canonicalize refetches messages from the server,
@@ -154,6 +167,18 @@ export interface AIHubProviderProps {
154
167
  * is empty. Server config (`theme_default`) wins after hydration.
155
168
  */
156
169
  defaultTheme?: ResolvedTheme;
170
+ /**
171
+ * Fallback UI language used when no `/auth/me` has resolved yet and the
172
+ * cache is empty. Server config (`language_default`) wins after hydration.
173
+ * Defaults to `"en"`.
174
+ */
175
+ defaultLanguage?: AIHubLanguage;
176
+ /**
177
+ * Wrapper-supplied `texts` override, merged on top of the selected
178
+ * built-in locale (`en` | `cs` | `sk`). Any key the wrapper sets wins over
179
+ * the locale; omitted keys fall back to the locale, then English.
180
+ */
181
+ texts?: DeepPartial<AIHubTexts>;
157
182
  /** The tree under the provider. */
158
183
  children: ReactNode;
159
184
  }
@@ -171,6 +196,8 @@ export function AIHubProvider(props: AIHubProviderProps): ReactNode {
171
196
  children,
172
197
  client: clientProp,
173
198
  defaultTheme = "light",
199
+ defaultLanguage = "en",
200
+ texts: textsOverride,
174
201
  fetch: fetchProp,
175
202
  onError,
176
203
  tokenStorage = "localStorage",
@@ -207,6 +234,12 @@ export function AIHubProvider(props: AIHubProviderProps): ReactNode {
207
234
  const [theme, setThemeState] = useState<ResolvedTheme>(() => {
208
235
  return readCachedTheme() ?? defaultTheme;
209
236
  });
237
+ // ── language / i18n ──────────────────────────────────────────────────
238
+ // Seed from the device cache (FOUC-free first paint); reconciled against
239
+ // the deployment default once /auth/me resolves (see effect below).
240
+ const [language, setLanguageState] = useState<AIHubLanguage>(() => {
241
+ return readCachedLanguage() ?? defaultLanguage;
242
+ });
210
243
 
211
244
  // ── chat state (Step 9) ─────────────────────────────────────────────
212
245
  const [agents, setAgents] = useState<AgentSummary[]>([]);
@@ -381,6 +414,21 @@ export function AIHubProvider(props: AIHubProviderProps): ReactNode {
381
414
  writeCachedTheme(resolved);
382
415
  }, [user, deployment, defaultTheme]);
383
416
 
417
+ // ── reconcile resolved language once the deployment default lands ──
418
+ // Seed-only: when switching is unlocked the device-cached choice wins and
419
+ // we keep it; when locked we force the deployment default WITHOUT touching
420
+ // the cache, so unlocking later restores the user's prior choice.
421
+ useEffect(() => {
422
+ if (!deployment) return;
423
+ const resolved = resolveLanguage(
424
+ readCachedLanguage(),
425
+ deployment,
426
+ defaultLanguage,
427
+ );
428
+ setLanguageState(resolved);
429
+ if (!deployment.user_language_locked) writeCachedLanguage(resolved);
430
+ }, [deployment, defaultLanguage]);
431
+
384
432
  // ── load /agents whenever an authenticated user becomes available ───
385
433
  const refreshAgents = useCallback(async (): Promise<void> => {
386
434
  setAgentsLoading(true);
@@ -526,6 +574,14 @@ export function AIHubProvider(props: AIHubProviderProps): ReactNode {
526
574
  }
527
575
  };
528
576
 
577
+ // Language is a device-local preference (no server round-trip): set state
578
+ // and cache it. No-op when the deployment has locked the language.
579
+ const setLanguage: AIHubContextValue["setLanguage"] = (next) => {
580
+ if (deployment?.user_language_locked) return;
581
+ setLanguageState(next);
582
+ writeCachedLanguage(next);
583
+ };
584
+
529
585
  // ── chat actions ───────────────────────────────────────────────────
530
586
  const setActiveAgent: AIHubContextValue["setActiveAgent"] = (id) => {
531
587
  streamRef.current?.controller.abort();
@@ -1003,6 +1059,13 @@ export function AIHubProvider(props: AIHubProviderProps): ReactNode {
1003
1059
  };
1004
1060
 
1005
1061
  const themeSwitcherEnabled = deployment?.theme_switcher_enabled ?? true;
1062
+ const languageSwitcherEnabled = !(deployment?.user_language_locked ?? false);
1063
+ // Resolve language-aware texts: the selected built-in locale with the
1064
+ // wrapper override merged on top. Recomputes when `language` flips.
1065
+ const texts: AIHubTexts = useMemo(
1066
+ () => resolveAIHubTexts(textsOverride, AIHUB_LOCALES[language]),
1067
+ [textsOverride, language],
1068
+ );
1006
1069
  const role: AIHubRole | null = user?.role ?? null;
1007
1070
 
1008
1071
  // Refresh the feature toggles on demand — used after the super-admin
@@ -1039,6 +1102,10 @@ export function AIHubProvider(props: AIHubProviderProps): ReactNode {
1039
1102
  theme,
1040
1103
  setTheme,
1041
1104
  themeSwitcherEnabled,
1105
+ language,
1106
+ setLanguage,
1107
+ languageSwitcherEnabled,
1108
+ texts,
1042
1109
  agents,
1043
1110
  agentsLoading,
1044
1111
  refreshAgents,
@@ -1074,7 +1141,7 @@ export function AIHubProvider(props: AIHubProviderProps): ReactNode {
1074
1141
 
1075
1142
  return (
1076
1143
  <aiHubContext.Provider value={value}>
1077
- <div className="data-club-ai-hub" data-theme={theme}>
1144
+ <div className="data-club-ai-hub" data-theme={theme} lang={language}>
1078
1145
  {children}
1079
1146
  </div>
1080
1147
  </aiHubContext.Provider>
@@ -7,3 +7,4 @@
7
7
  export { AIHubProvider, type AIHubProviderProps } from "./AIHubProvider.js";
8
8
  export { useAIHub, type UseAIHubResult } from "./useAIHub.js";
9
9
  export { useAIHubAuth, type UseAIHubAuthResult } from "./useAIHubAuth.js";
10
+ export { useAIHubTexts, useResolvedTexts } from "./useAIHubTexts.js";
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Language resolution helpers used by `<AIHubProvider>` — the i18n analogue
3
+ * of `theme.ts`.
4
+ *
5
+ * Precedence (mirrors §7.6 theming):
6
+ *
7
+ * 1. When `deployment.user_language_locked` is true → `deployment.language_default`
8
+ * (the user's cached choice is ignored and the switcher is hidden).
9
+ * 2. Otherwise the value cached in `localStorage` (the user's last pick on
10
+ * this device), then `deployment.language_default`, then `fallback`.
11
+ *
12
+ * The cache also gives a FOUC-free first paint: before `/auth/me` resolves we
13
+ * render in the last-known language rather than flashing English.
14
+ *
15
+ * @see ./theme.ts — the theme counterpart this mirrors
16
+ */
17
+
18
+ import type { AIHubLanguage } from "../auth/texts.js";
19
+ import type { AIHubDeployment } from "../types.js";
20
+
21
+ /** localStorage key for the last-chosen language. Namespaced to ai-hub. */
22
+ const CACHE_KEY = "data-club-ai-hub:language";
23
+
24
+ /** The languages the library ships built-in locales for. */
25
+ const KNOWN: ReadonlyArray<AIHubLanguage> = ["en", "cs", "sk"];
26
+
27
+ /** Type guard: is `value` one of the known language codes? */
28
+ function isLanguage(value: unknown): value is AIHubLanguage {
29
+ return typeof value === "string" && (KNOWN as string[]).includes(value);
30
+ }
31
+
32
+ /**
33
+ * Computes the resolved language from the `/auth/me` deployment context, the
34
+ * cached user choice, and a fallback.
35
+ *
36
+ * @param cached - the user's cached choice (or null).
37
+ * @param deployment - deployment context, or null when unauthenticated.
38
+ * @param fallback - default applied when nothing else resolves (usually `"en"`).
39
+ * @returns the resolved language.
40
+ */
41
+ export function resolveLanguage(
42
+ cached: AIHubLanguage | null,
43
+ deployment: AIHubDeployment | null,
44
+ fallback: AIHubLanguage,
45
+ ): AIHubLanguage {
46
+ const deploymentDefault = deployment?.language_default ?? fallback;
47
+ if (deployment?.user_language_locked) return deploymentDefault;
48
+ return cached ?? deploymentDefault;
49
+ }
50
+
51
+ /**
52
+ * Reads the cached language from `localStorage`. Returns null when no cache
53
+ * exists, the value is malformed, or storage is unavailable (Safari private
54
+ * mode, SSR, etc.).
55
+ *
56
+ * @returns the cached language or null.
57
+ */
58
+ export function readCachedLanguage(): AIHubLanguage | null {
59
+ try {
60
+ if (typeof window === "undefined" || !window.localStorage) return null;
61
+ const raw = window.localStorage.getItem(CACHE_KEY);
62
+ return isLanguage(raw) ? raw : null;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Persists the chosen language into `localStorage` for FOUC avoidance on the
70
+ * next page load. Silent no-op on storage failures.
71
+ *
72
+ * @param language - the language to cache.
73
+ */
74
+ export function writeCachedLanguage(language: AIHubLanguage): void {
75
+ try {
76
+ if (typeof window === "undefined" || !window.localStorage) return;
77
+ window.localStorage.setItem(CACHE_KEY, language);
78
+ } catch {
79
+ /* ignore */
80
+ }
81
+ }
@@ -36,6 +36,14 @@ export interface UseAIHubResult {
36
36
  setTheme: AIHubContextValue["setTheme"];
37
37
  /** Mirror of `deployment.theme_switcher_enabled`. */
38
38
  themeSwitcherEnabled: AIHubContextValue["themeSwitcherEnabled"];
39
+ /** Resolved UI language (`en` | `cs` | `sk`). */
40
+ language: AIHubContextValue["language"];
41
+ /** Switches the UI language (device-cached). No-op when locked. */
42
+ setLanguage: AIHubContextValue["setLanguage"];
43
+ /** Mirror of `!deployment.user_language_locked`. */
44
+ languageSwitcherEnabled: AIHubContextValue["languageSwitcherEnabled"];
45
+ /** Fully-resolved, language-aware texts. Prefer `useAIHubTexts()`. */
46
+ texts: AIHubContextValue["texts"];
39
47
  /** Non-archived agents available to the caller. */
40
48
  agents: AIHubContextValue["agents"];
41
49
  /** True while the initial `/agents` request is in flight. */
@@ -120,6 +128,10 @@ export function useAIHub(): UseAIHubResult {
120
128
  theme: ctx.theme,
121
129
  setTheme: ctx.setTheme,
122
130
  themeSwitcherEnabled: ctx.themeSwitcherEnabled,
131
+ language: ctx.language,
132
+ setLanguage: ctx.setLanguage,
133
+ languageSwitcherEnabled: ctx.languageSwitcherEnabled,
134
+ texts: ctx.texts,
123
135
  agents: ctx.agents,
124
136
  agentsLoading: ctx.agentsLoading,
125
137
  refreshAgents: ctx.refreshAgents,
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `useAIHubTexts()` — returns the fully-resolved, language-aware
3
+ * {@link AIHubTexts} from the nearest `<AIHubProvider>`.
4
+ *
5
+ * This is the supported way for built-in screens to read user-facing
6
+ * strings: the provider has already merged the active built-in locale
7
+ * (`en` | `cs` | `sk`) with any wrapper `texts` override, so flipping the
8
+ * language via `setLanguage` re-renders every consumer.
9
+ *
10
+ * @see ./AIHubProvider.tsx — where `texts` is computed
11
+ */
12
+
13
+ import { useContext, useMemo } from "react";
14
+
15
+ import {
16
+ defaultAIHubTexts,
17
+ resolveAIHubTexts,
18
+ type AIHubTexts,
19
+ type DeepPartial,
20
+ } from "../auth/texts.js";
21
+ import { aiHubContext } from "./AIHubContext.js";
22
+
23
+ /**
24
+ * Returns the resolved texts map.
25
+ *
26
+ * Throws when called outside a provider — surfaces mounting bugs early
27
+ * instead of cascading through context-null deref.
28
+ *
29
+ * @returns the resolved `AIHubTexts`.
30
+ */
31
+ export function useAIHubTexts(): AIHubTexts {
32
+ const ctx = useContext(aiHubContext);
33
+ if (!ctx) {
34
+ throw new Error(
35
+ "useAIHubTexts() was called outside <AIHubProvider>. Wrap the tree in <AIHubProvider> first.",
36
+ );
37
+ }
38
+ return ctx.texts;
39
+ }
40
+
41
+ /**
42
+ * Resolves a component's optional `texts` override against the **active
43
+ * language locale** from the nearest `<AIHubProvider>` (the supported way
44
+ * for built-in components to be language-aware).
45
+ *
46
+ * - Inside a provider: the provider's already-resolved, language-aware
47
+ * `texts` is the base; the component's own `override` (if any) is merged on
48
+ * top. Flipping `setLanguage` re-renders the provider, so every consumer
49
+ * re-resolves automatically.
50
+ * - Outside a provider (standalone component / Storybook / tests): falls back
51
+ * to the English `defaultAIHubTexts` as the base, preserving the original
52
+ * "merge over English defaults" behavior.
53
+ *
54
+ * @param override - the component's wrapper-supplied partial (usually
55
+ * `props.texts`). `undefined` is fine.
56
+ * @returns the fully resolved `AIHubTexts`.
57
+ */
58
+ export function useResolvedTexts(
59
+ override: DeepPartial<AIHubTexts> | undefined,
60
+ ): AIHubTexts {
61
+ const ctx = useContext(aiHubContext);
62
+ const base = ctx?.texts ?? defaultAIHubTexts;
63
+ return useMemo(() => resolveAIHubTexts(override, base), [override, base]);
64
+ }
package/src/index.ts CHANGED
@@ -90,6 +90,8 @@ export {
90
90
  AIHubProvider,
91
91
  useAIHub,
92
92
  useAIHubAuth,
93
+ useAIHubTexts,
94
+ useResolvedTexts,
93
95
  } from "./context/index.js";
94
96
  export type {
95
97
  AIHubProviderProps,
@@ -205,6 +207,8 @@ export type {
205
207
  ThemeSwitcherClassNames,
206
208
  } from "./components/ThemeSwitcher/index.js";
207
209
 
210
+ export { LanguageSwitcher } from "./components/LanguageSwitcher/index.js";
211
+
208
212
  export { SystemNotice } from "./components/SystemNotice/index.js";
209
213
  export type {
210
214
  SystemNoticeProps,
@@ -249,6 +253,7 @@ export {
249
253
  } from "./auth/texts.js";
250
254
  export type {
251
255
  AIHubTexts,
256
+ AIHubLanguage,
252
257
  AIHubLoginTexts,
253
258
  AIHubChangePasswordTexts,
254
259
  AIHubChatTexts,
@@ -256,6 +261,14 @@ export type {
256
261
  DeepPartial,
257
262
  } from "./auth/texts.js";
258
263
 
264
+ /* Built-in locales (Czech / Slovak) + the switcher's language metadata. */
265
+ export {
266
+ AIHUB_LOCALES,
267
+ AIHUB_LANGUAGE_OPTIONS,
268
+ csTexts,
269
+ skTexts,
270
+ } from "./auth/locales/index.js";
271
+
259
272
  /* ── Password policy (client-side mirror of §8.5) ────────────────── */
260
273
 
261
274
  export {
@@ -29,6 +29,7 @@ import { ReasoningView } from "../components/ReasoningView/index.js";
29
29
  import { ScrollToPresentButton } from "../components/ScrollToPresentButton/index.js";
30
30
  import { SessionsPanel } from "../components/SessionsPanel/index.js";
31
31
  import { SystemNotice } from "../components/SystemNotice/index.js";
32
+ import { LanguageSwitcher } from "../components/LanguageSwitcher/index.js";
32
33
  import { ThemeSwitcher } from "../components/ThemeSwitcher/index.js";
33
34
  import { ToolCallView } from "../components/ToolCallView/index.js";
34
35
  import { TpmBanner } from "../components/TpmBanner/index.js";
@@ -87,6 +88,8 @@ export const AIHub = {
87
88
  TurnStatusIndicator,
88
89
  /** Sun/moon theme toggle. */
89
90
  ThemeSwitcher,
91
+ /** Globe language switcher (en/cs/sk). */
92
+ LanguageSwitcher,
90
93
  /** Floating "↓ Jump to latest" button. */
91
94
  ScrollToPresentButton,
92
95
  /** Collapsible reasoning block inside an assistant bubble. */
@@ -254,14 +254,36 @@ export function chatAffordances(state: AIHubState): AIHubChatAffordances {
254
254
  * `useEffect` + a short timer.
255
255
  */
256
256
  export interface AIHubTurnStatusVisual {
257
- /** Short human-readable label rendered next to the dot. */
257
+ /**
258
+ * Short human-readable label rendered next to the dot. **English** — this
259
+ * is the fallback for consumers that read `turnStatusVisual` directly. The
260
+ * `<TurnStatusIndicator />` primitive renders the localized
261
+ * `texts.chat[labelKey]` instead.
262
+ */
258
263
  label: string;
264
+ /**
265
+ * The `AIHubChatTexts` key for the localized label. Each value must name an
266
+ * existing `turnStatus*` key in the chat texts.
267
+ */
268
+ labelKey: TurnStatusLabelKey;
259
269
  /** Dot variant — drives the BEM modifier on the dot element. */
260
270
  dot: "idle" | "running" | "warning" | "error";
261
271
  /** Whether the dot pulses (suppressed under `prefers-reduced-motion`). */
262
272
  pulse: boolean;
263
273
  }
264
274
 
275
+ /** The `turnStatus*` keys in `AIHubChatTexts` a status can resolve to. */
276
+ export type TurnStatusLabelKey =
277
+ | "turnStatusReady"
278
+ | "turnStatusGenerating"
279
+ | "turnStatusReasoning"
280
+ | "turnStatusPreparingTool"
281
+ | "turnStatusRunningTool"
282
+ | "turnStatusWaitingRateLimit"
283
+ | "turnStatusFailed"
284
+ | "turnStatusCancelled"
285
+ | "turnStatusDone";
286
+
265
287
  /**
266
288
  * Computes the §10.4 turn-status indicator's `(label, dot, pulse)` from
267
289
  * a `(status, phase)` pair.
@@ -281,20 +303,22 @@ export function turnStatusVisual(
281
303
  if (status === "running") {
282
304
  switch (phase) {
283
305
  case "model_text":
284
- return { label: "Generating…", dot: "running", pulse: true };
306
+ return { label: "Generating…", labelKey: "turnStatusGenerating", dot: "running", pulse: true };
285
307
  case "model_reasoning":
286
- return { label: "Reasoning…", dot: "running", pulse: true };
308
+ return { label: "Reasoning…", labelKey: "turnStatusReasoning", dot: "running", pulse: true };
287
309
  case "model_tool_input":
288
310
  return {
289
311
  label: "Preparing tool call…",
312
+ labelKey: "turnStatusPreparingTool",
290
313
  dot: "running",
291
314
  pulse: true,
292
315
  };
293
316
  case "tool_executing":
294
- return { label: "Running tool…", dot: "running", pulse: true };
317
+ return { label: "Running tool…", labelKey: "turnStatusRunningTool", dot: "running", pulse: true };
295
318
  case "waiting_provider":
296
319
  return {
297
320
  label: "Waiting (rate limit)…",
321
+ labelKey: "turnStatusWaitingRateLimit",
298
322
  dot: "warning",
299
323
  pulse: true,
300
324
  };
@@ -302,20 +326,20 @@ export function turnStatusVisual(
302
326
  // Not exercised by the predicate table (the spec doesn't surface
303
327
  // a label for awaiting_user); fall back to "Ready" so the dot
304
328
  // doesn't strobe.
305
- return { label: "Ready", dot: "idle", pulse: false };
329
+ return { label: "Ready", labelKey: "turnStatusReady", dot: "idle", pulse: false };
306
330
  case null:
307
- return { label: "Generating…", dot: "running", pulse: true };
331
+ return { label: "Generating…", labelKey: "turnStatusGenerating", dot: "running", pulse: true };
308
332
  }
309
333
  }
310
334
  if (status === "failed") {
311
- return { label: "Failed", dot: "error", pulse: false };
335
+ return { label: "Failed", labelKey: "turnStatusFailed", dot: "error", pulse: false };
312
336
  }
313
337
  if (status === "cancelled") {
314
- return { label: "Cancelled", dot: "idle", pulse: false };
338
+ return { label: "Cancelled", labelKey: "turnStatusCancelled", dot: "idle", pulse: false };
315
339
  }
316
340
  if (status === "done") {
317
- return { label: "Done", dot: "idle", pulse: false };
341
+ return { label: "Done", labelKey: "turnStatusDone", dot: "idle", pulse: false };
318
342
  }
319
343
  // idle
320
- return { label: "Ready", dot: "idle", pulse: false };
344
+ return { label: "Ready", labelKey: "turnStatusReady", dot: "idle", pulse: false };
321
345
  }
@@ -15,6 +15,7 @@
15
15
  import { type ReactNode } from "react";
16
16
 
17
17
  import { aiHubContext, type AIHubContextValue } from "../context/AIHubContext.js";
18
+ import { defaultAIHubTexts } from "../auth/texts.js";
18
19
  import type { AIHubEffort } from "../client/AIHubClient.js";
19
20
  import type {
20
21
  AIHubFeatureToggles,
@@ -82,6 +83,10 @@ export function MockProvider(props: {
82
83
  theme: opts.theme ?? "light",
83
84
  setTheme: noopAsync,
84
85
  themeSwitcherEnabled: opts.themeSwitcherEnabled ?? true,
86
+ language: "en",
87
+ setLanguage: () => undefined,
88
+ languageSwitcherEnabled: true,
89
+ texts: defaultAIHubTexts,
85
90
  agents: [],
86
91
  agentsLoading: false,
87
92
  refreshAgents: noopAsync,