@data-club/ai-hub 0.0.12 → 0.0.14

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 (66) hide show
  1. package/dist/auth/locales/cs.d.ts.map +1 -1
  2. package/dist/auth/locales/cs.js +1 -0
  3. package/dist/auth/locales/cs.js.map +1 -1
  4. package/dist/auth/locales/sk.d.ts.map +1 -1
  5. package/dist/auth/locales/sk.js +1 -0
  6. package/dist/auth/locales/sk.js.map +1 -1
  7. package/dist/auth/texts.d.ts +5 -0
  8. package/dist/auth/texts.d.ts.map +1 -1
  9. package/dist/auth/texts.js +1 -0
  10. package/dist/auth/texts.js.map +1 -1
  11. package/dist/components/ChatShell/index.d.ts +4 -0
  12. package/dist/components/ChatShell/index.d.ts.map +1 -1
  13. package/dist/components/ChatShell/index.js +64 -3
  14. package/dist/components/ChatShell/index.js.map +1 -1
  15. package/dist/components/DataClubAIHub/index.d.ts +4 -0
  16. package/dist/components/DataClubAIHub/index.d.ts.map +1 -1
  17. package/dist/components/DataClubAIHub/index.js +4 -0
  18. package/dist/components/DataClubAIHub/index.js.map +1 -1
  19. package/dist/components/DataClubAIHubAdmin/SettingsPage.d.ts.map +1 -1
  20. package/dist/components/DataClubAIHubAdmin/SettingsPage.js +18 -5
  21. package/dist/components/DataClubAIHubAdmin/SettingsPage.js.map +1 -1
  22. package/dist/components/MainColumn/index.d.ts +13 -0
  23. package/dist/components/MainColumn/index.d.ts.map +1 -1
  24. package/dist/components/MainColumn/index.js +4 -2
  25. package/dist/components/MainColumn/index.js.map +1 -1
  26. package/dist/components/SessionsPanel/index.d.ts +16 -0
  27. package/dist/components/SessionsPanel/index.d.ts.map +1 -1
  28. package/dist/components/SessionsPanel/index.js +83 -43
  29. package/dist/components/SessionsPanel/index.js.map +1 -1
  30. package/dist/context/AIHubContext.d.ts +24 -0
  31. package/dist/context/AIHubContext.d.ts.map +1 -1
  32. package/dist/context/AIHubContext.js.map +1 -1
  33. package/dist/context/AIHubProvider.d.ts +8 -0
  34. package/dist/context/AIHubProvider.d.ts.map +1 -1
  35. package/dist/context/AIHubProvider.js +60 -3
  36. package/dist/context/AIHubProvider.js.map +1 -1
  37. package/dist/context/useAIHub.d.ts +6 -0
  38. package/dist/context/useAIHub.d.ts.map +1 -1
  39. package/dist/context/useAIHub.js +3 -0
  40. package/dist/context/useAIHub.js.map +1 -1
  41. package/dist/state/affordances.d.ts +5 -1
  42. package/dist/state/affordances.d.ts.map +1 -1
  43. package/dist/state/affordances.js +5 -1
  44. package/dist/state/affordances.js.map +1 -1
  45. package/dist/stories/_mockProvider.d.ts.map +1 -1
  46. package/dist/stories/_mockProvider.js +3 -0
  47. package/dist/stories/_mockProvider.js.map +1 -1
  48. package/dist/types.d.ts +13 -0
  49. package/dist/types.d.ts.map +1 -1
  50. package/package.json +1 -1
  51. package/src/auth/locales/cs.ts +1 -0
  52. package/src/auth/locales/sk.ts +1 -0
  53. package/src/auth/texts.ts +6 -0
  54. package/src/components/ChatShell/index.tsx +63 -0
  55. package/src/components/DataClubAIHub/index.tsx +10 -0
  56. package/src/components/DataClubAIHubAdmin/SettingsPage.tsx +19 -5
  57. package/src/components/MainColumn/index.tsx +30 -0
  58. package/src/components/SessionsPanel/index.tsx +90 -7
  59. package/src/context/AIHubContext.ts +26 -0
  60. package/src/context/AIHubProvider.tsx +78 -1
  61. package/src/context/useAIHub.ts +9 -0
  62. package/src/state/affordances.ts +9 -1
  63. package/src/stories/_mockProvider.tsx +3 -0
  64. package/src/styles/CLASSES.md +19 -0
  65. package/src/styles/default.css +284 -0
  66. package/src/types.ts +13 -0
@@ -90,8 +90,12 @@ export interface DataClubAIHubProps {
90
90
  sessionsPanelWidthPercent?: number;
91
91
  /** Default for sessions_panel_collapsible. Default true. */
92
92
  sessionsPanelCollapsible?: boolean;
93
+ /** Default for sessions_panel_default_collapsed. Default **false** (extended). */
94
+ sessionsPanelDefaultCollapsed?: boolean;
93
95
  /** Default for create_session_button_enabled. Default true. */
94
96
  showCreateSessionButton?: boolean;
97
+ /** Default for load_last_session_enabled. Default **false**. */
98
+ loadLastSession?: boolean;
95
99
 
96
100
  /* ── Input controls (§8.4) ─────────────────────────────────────── */
97
101
  /** Default for user_input_enabled. Default true. */
@@ -265,6 +269,12 @@ function AuthedShell({ props }: { props: DataClubAIHubProps }): ReactNode {
265
269
  {...(props.sessionsPanelCollapsible !== undefined
266
270
  ? { sessionsPanelCollapsible: props.sessionsPanelCollapsible }
267
271
  : {})}
272
+ {...(props.sessionsPanelDefaultCollapsed !== undefined
273
+ ? { sessionsPanelDefaultCollapsed: props.sessionsPanelDefaultCollapsed }
274
+ : {})}
275
+ {...(props.loadLastSession !== undefined
276
+ ? { loadLastSession: props.loadLastSession }
277
+ : {})}
268
278
  {...(props.sessionsPanelResizable !== undefined
269
279
  ? { sessionsPanelResizable: props.sessionsPanelResizable }
270
280
  : {})}
@@ -43,7 +43,9 @@ const ALL_TOGGLE_KEYS: ReadonlyArray<keyof AIHubFeatureToggles> = [
43
43
  "sessions_panel_enabled",
44
44
  "sessions_panel_resizable",
45
45
  "sessions_panel_collapsible",
46
+ "sessions_panel_default_collapsed",
46
47
  "create_session_button_enabled",
48
+ "load_last_session_enabled",
47
49
  "user_input_enabled",
48
50
  "stop_button_enabled",
49
51
  "tool_calls_visible",
@@ -67,7 +69,9 @@ const TOGGLE_GROUPS: ReadonlyArray<{
67
69
  "sessions_panel_enabled",
68
70
  "sessions_panel_resizable",
69
71
  "sessions_panel_collapsible",
72
+ "sessions_panel_default_collapsed",
70
73
  "create_session_button_enabled",
74
+ "load_last_session_enabled",
71
75
  ],
72
76
  },
73
77
  {
@@ -89,9 +93,19 @@ const TOGGLE_GROUPS: ReadonlyArray<{
89
93
  },
90
94
  ];
91
95
 
92
- /** Toggle defaults — every toggle is on by default per §8.4. */
93
- function defaultToggleValue(): boolean {
94
- return true;
96
+ /**
97
+ * Built-in defaults per §8.4 — most toggles are on by default; the two
98
+ * opt-in behaviors below default to off. Must mirror
99
+ * `DEFAULT_AFFORDANCE_FLAGS` in `state/affordances.ts`.
100
+ */
101
+ const OFF_BY_DEFAULT: ReadonlySet<keyof AIHubFeatureToggles> = new Set([
102
+ "sessions_panel_default_collapsed",
103
+ "load_last_session_enabled",
104
+ ] as const);
105
+
106
+ /** Built-in default for one toggle key. */
107
+ function defaultToggleValue(key: keyof AIHubFeatureToggles): boolean {
108
+ return !OFF_BY_DEFAULT.has(key);
95
109
  }
96
110
 
97
111
  /** Format ISO date as `<localized date> (<localized N days ago>)`. */
@@ -616,12 +630,12 @@ function FeatureTogglesSection(props: FeatureTogglesSectionProps): ReactNode {
616
630
  // The server's blob may omit keys (meaning "fall through to defaults"). The
617
631
  // UI shows the effective value for the user — fill in defaults for any
618
632
  // missing key. Save only sends the keys the user actually has toggled (in
619
- // our case all of them, since the blob always carries all 14 once saved).
633
+ // our case all of them, since the blob always carries all 16 once saved).
620
634
  const [values, setValues] = useState<Record<string, boolean>>(() => {
621
635
  const initial: Record<string, boolean> = {};
622
636
  for (const key of ALL_TOGGLE_KEYS) {
623
637
  const fromServer = props.settings.feature_toggles[key];
624
- initial[key] = typeof fromServer === "boolean" ? fromServer : defaultToggleValue();
638
+ initial[key] = typeof fromServer === "boolean" ? fromServer : defaultToggleValue(key);
625
639
  }
626
640
  return initial;
627
641
  });
@@ -37,6 +37,11 @@ export interface MainColumnClassNames {
37
37
  root: string;
38
38
  /** Chat header strip at the top. */
39
39
  header: string;
40
+ /**
41
+ * Hamburger button that opens the sessions drawer — rendered only in
42
+ * the narrow (mobile) layout.
43
+ */
44
+ menu: string;
40
45
  /** Back-to-agents-picker link. */
41
46
  back: string;
42
47
  /** Agent display-name text. */
@@ -52,6 +57,7 @@ export interface MainColumnClassNames {
52
57
  const DEFAULT_CLASS_NAMES: MainColumnClassNames = Object.freeze({
53
58
  root: "data-club-ai-hub__main-column",
54
59
  header: "data-club-ai-hub__chat-header",
60
+ menu: "data-club-ai-hub__chat-header__menu",
55
61
  back: "data-club-ai-hub__chat-header__back",
56
62
  title: "data-club-ai-hub__chat-header__title",
57
63
  export: "data-club-ai-hub__chat-header__export",
@@ -69,6 +75,14 @@ export interface MainColumnProps {
69
75
  * the link in single-agent embeds). Defaults to true.
70
76
  */
71
77
  showBackToAgents?: boolean;
78
+ /**
79
+ * Whether the narrow-layout hamburger button (opens the sessions
80
+ * drawer) may render. `<ChatShell />` passes the resolved
81
+ * `sessions_panel_enabled` flag so the button never appears for
82
+ * deployments without a sessions panel. The button only renders while
83
+ * the container is in the narrow layout regardless. Defaults to true.
84
+ */
85
+ showMenuButton?: boolean;
72
86
  /**
73
87
  * Whether to mount the turn-status indicator in the chat header.
74
88
  * Receives the resolved §8.4 flag stack (server toggle > prop >
@@ -107,10 +121,13 @@ export function MainColumn(props: MainColumnProps): ReactNode {
107
121
  sessions,
108
122
  activeSessionId,
109
123
  messages,
124
+ isNarrowLayout,
125
+ setSessionsDrawerOpen,
110
126
  } = useAIHub();
111
127
  const texts = useResolvedTexts(props.texts);
112
128
  const cls = { ...DEFAULT_CLASS_NAMES, ...(props.classNames ?? {}) };
113
129
  const showBack = props.showBackToAgents ?? true;
130
+ const showMenuButton = (props.showMenuButton ?? true) && isNarrowLayout;
114
131
  const showTurnStatus = props.showTurnStatus ?? true;
115
132
  const showExportButton = props.showExportButton ?? true;
116
133
 
@@ -176,6 +193,19 @@ export function MainColumn(props: MainColumnProps): ReactNode {
176
193
  return (
177
194
  <section className={rootClass} data-testid="main-column">
178
195
  <header className={cls.header} data-testid="chat-header">
196
+ {showMenuButton ? (
197
+ <button
198
+ type="button"
199
+ className={cls.menu}
200
+ onClick={() => setSessionsDrawerOpen(true)}
201
+ aria-label={texts.chat.expandSessionsPanel}
202
+ data-testid="chat-header-menu"
203
+ >
204
+ {/* Hamburger glyph — consistent with the text-glyph icons used
205
+ elsewhere (≪ / ≫ / ✎); themed via currentColor. */}
206
+
207
+ </button>
208
+ ) : null}
179
209
  {showBack ? (
180
210
  <button
181
211
  type="button"
@@ -48,6 +48,17 @@ export interface SessionsPanelClassNames {
48
48
  root: string;
49
49
  /** Modifier applied when the panel is collapsed. */
50
50
  rootCollapsed: string;
51
+ /**
52
+ * Modifier applied in the narrow (mobile) layout, where the panel
53
+ * renders as an overlay drawer instead of an inline column.
54
+ */
55
+ rootDrawer: string;
56
+ /** Modifier applied while the mobile drawer is open. */
57
+ rootDrawerOpen: string;
58
+ /** Backdrop scrim behind the open mobile drawer. */
59
+ drawerScrim: string;
60
+ /** Close (✕) button at the top of the mobile drawer. */
61
+ drawerClose: string;
51
62
  /** Optional custom-header slot wrapper at the top of the panel. */
52
63
  customHeader: string;
53
64
  /** "+ New chat" button. */
@@ -87,6 +98,10 @@ export interface SessionsPanelClassNames {
87
98
  const DEFAULT_CLASS_NAMES: SessionsPanelClassNames = Object.freeze({
88
99
  root: "data-club-ai-hub__sessions-panel",
89
100
  rootCollapsed: "data-club-ai-hub__sessions-panel--collapsed",
101
+ rootDrawer: "data-club-ai-hub__sessions-panel--drawer",
102
+ rootDrawerOpen: "data-club-ai-hub__sessions-panel--drawer-open",
103
+ drawerScrim: "data-club-ai-hub__sessions-panel__scrim",
104
+ drawerClose: "data-club-ai-hub__sessions-panel__drawer-close",
90
105
  customHeader: "data-club-ai-hub__sessions-panel__custom-header",
91
106
  newSessionButton: "data-club-ai-hub__sessions-panel__new-button",
92
107
  reveal: "data-club-ai-hub__sessions-panel__reveal",
@@ -114,6 +129,11 @@ export interface SessionsPanelProps {
114
129
  showCreateButton?: boolean;
115
130
  /** Whether the panel can be collapsed. Defaults to true. */
116
131
  collapsible?: boolean;
132
+ /**
133
+ * Whether the panel starts collapsed (until the user toggles it).
134
+ * Defaults to false — panel starts extended.
135
+ */
136
+ defaultCollapsed?: boolean;
117
137
  /** Top-of-panel slot. */
118
138
  customHeader?: ReactNode;
119
139
  /** Bottom-of-panel slot, rendered above the archived-filter toggle. */
@@ -143,16 +163,42 @@ export function SessionsPanel(props: SessionsPanelProps): ReactNode {
143
163
  includeArchivedSessions,
144
164
  setIncludeArchivedSessions,
145
165
  language,
166
+ isNarrowLayout,
167
+ sessionsDrawerOpen,
168
+ setSessionsDrawerOpen,
146
169
  } = useAIHub();
147
170
  const texts = useResolvedTexts(props.texts);
148
171
  const cls = { ...DEFAULT_CLASS_NAMES, ...(props.classNames ?? {}) };
149
172
  const showCreate = props.showCreateButton ?? true;
150
173
  const collapsible = props.collapsible ?? true;
151
174
 
152
- const [collapsed, setCollapsed] = useState(false);
175
+ // `null` = the user hasn't toggled yet — follow `defaultCollapsed`. This
176
+ // matters because the server's feature-toggle blob resolves async: a
177
+ // late-arriving `sessions_panel_default_collapsed=true` still applies as
178
+ // long as the user hasn't made an explicit choice.
179
+ const [userCollapsed, setUserCollapsed] = useState<boolean | null>(null);
180
+ const collapsed = userCollapsed ?? props.defaultCollapsed ?? false;
153
181
  const [modalOpen, setModalOpen] = useState(false);
154
182
  const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
155
183
 
184
+ // Narrow (mobile) layout: the panel becomes an overlay drawer driven by
185
+ // shared context state (the open button lives in `<MainColumn>`'s chat
186
+ // header). Desktop-only affordances — collapse toggle / reveal strip —
187
+ // are suppressed while in drawer mode.
188
+ const drawerMode = isNarrowLayout;
189
+ const drawerOpen = drawerMode && sessionsDrawerOpen;
190
+
191
+ // Escape closes the open drawer (matches the dropdown/modal conventions
192
+ // elsewhere in the library).
193
+ useEffect(() => {
194
+ if (!drawerOpen) return undefined;
195
+ const onKeyDown = (event: KeyboardEvent): void => {
196
+ if (event.key === "Escape") setSessionsDrawerOpen(false);
197
+ };
198
+ document.addEventListener("keydown", onKeyDown);
199
+ return () => document.removeEventListener("keydown", onKeyDown);
200
+ }, [drawerOpen, setSessionsDrawerOpen]);
201
+
156
202
  const editingSession = useMemo<Session | null>(() => {
157
203
  if (!editingSessionId) return null;
158
204
  return sessions.find((s) => s.id === editingSessionId) ?? null;
@@ -166,7 +212,7 @@ export function SessionsPanel(props: SessionsPanelProps): ReactNode {
166
212
  }
167
213
  }, [editingSessionId, editingSession]);
168
214
 
169
- if (collapsed) {
215
+ if (collapsed && !drawerMode) {
170
216
  return (
171
217
  <div
172
218
  className={`${cls.root} ${cls.rootCollapsed}`}
@@ -176,7 +222,7 @@ export function SessionsPanel(props: SessionsPanelProps): ReactNode {
176
222
  <button
177
223
  type="button"
178
224
  className={cls.reveal}
179
- onClick={() => setCollapsed(false)}
225
+ onClick={() => setUserCollapsed(false)}
180
226
  aria-label={texts.chat.expandSessionsPanel}
181
227
  data-testid="sessions-panel-reveal"
182
228
  >
@@ -189,7 +235,14 @@ export function SessionsPanel(props: SessionsPanelProps): ReactNode {
189
235
  );
190
236
  }
191
237
 
192
- const rootClass = [cls.root, props.className].filter(Boolean).join(" ");
238
+ const rootClass = [
239
+ cls.root,
240
+ drawerMode ? cls.rootDrawer : "",
241
+ drawerOpen ? cls.rootDrawerOpen : "",
242
+ props.className,
243
+ ]
244
+ .filter(Boolean)
245
+ .join(" ");
193
246
 
194
247
  async function handleArchive(session: Session): Promise<void> {
195
248
  const prompt = texts.chat.archiveConfirmPrompt.replace(
@@ -216,11 +269,35 @@ export function SessionsPanel(props: SessionsPanelProps): ReactNode {
216
269
  }
217
270
 
218
271
  return (
272
+ <>
273
+ {drawerMode ? (
274
+ <div
275
+ className={cls.drawerScrim}
276
+ data-open={drawerOpen ? "true" : "false"}
277
+ onClick={() => setSessionsDrawerOpen(false)}
278
+ aria-hidden="true"
279
+ data-testid="sessions-drawer-scrim"
280
+ />
281
+ ) : null}
219
282
  <aside
220
283
  className={rootClass}
221
284
  data-testid="sessions-panel"
222
285
  aria-label="Sessions"
286
+ data-drawer={drawerMode ? "true" : undefined}
287
+ data-open={drawerMode ? (drawerOpen ? "true" : "false") : undefined}
288
+ aria-hidden={drawerMode && !drawerOpen ? true : undefined}
223
289
  >
290
+ {drawerMode ? (
291
+ <button
292
+ type="button"
293
+ className={cls.drawerClose}
294
+ onClick={() => setSessionsDrawerOpen(false)}
295
+ aria-label={texts.chat.collapseSessionsPanel}
296
+ data-testid="sessions-drawer-close"
297
+ >
298
+
299
+ </button>
300
+ ) : null}
224
301
  {props.customHeader ? (
225
302
  <div className={cls.customHeader} data-testid="sessions-panel-custom-header">
226
303
  {props.customHeader}
@@ -239,11 +316,11 @@ export function SessionsPanel(props: SessionsPanelProps): ReactNode {
239
316
  {texts.chat.newSessionButton}
240
317
  </button>
241
318
  ) : null}
242
- {collapsible ? (
319
+ {collapsible && !drawerMode ? (
243
320
  <button
244
321
  type="button"
245
322
  className={cls.collapseToggle}
246
- onClick={() => setCollapsed(true)}
323
+ onClick={() => setUserCollapsed(true)}
247
324
  aria-label={texts.chat.collapseSessionsPanel}
248
325
  data-testid="sessions-panel-collapse"
249
326
  >
@@ -286,7 +363,12 @@ export function SessionsPanel(props: SessionsPanelProps): ReactNode {
286
363
  <button
287
364
  type="button"
288
365
  className={cls.itemTitle}
289
- onClick={() => setActiveSession(session.id)}
366
+ onClick={() => {
367
+ setActiveSession(session.id);
368
+ // Re-selecting the already-active session doesn't change
369
+ // provider state, so close the drawer explicitly too.
370
+ if (drawerMode) setSessionsDrawerOpen(false);
371
+ }}
290
372
  data-testid="session-item-open"
291
373
  >
292
374
  <span>{displayTitle}</span>
@@ -380,5 +462,6 @@ export function SessionsPanel(props: SessionsPanelProps): ReactNode {
380
462
  texts={texts.chat}
381
463
  />
382
464
  </aside>
465
+ </>
383
466
  );
384
467
  }
@@ -263,6 +263,32 @@ export interface AIHubContextValue {
263
263
  * effect without a page reload.
264
264
  */
265
265
  refreshFeatureToggles: () => Promise<void>;
266
+
267
+ /* ── responsive layout ─────────────────────────────────────────── */
268
+ /**
269
+ * True when the component root is narrower than the narrow-layout
270
+ * breakpoint (default 720px). Driven by a `ResizeObserver` on the
271
+ * provider's root element — *container* width, not viewport width —
272
+ * so the library adapts identically in full-page mounts, iframes and
273
+ * partial-width embeds. The provider mirrors this value onto the
274
+ * root element as `data-layout="narrow" | "wide"`, which is what the
275
+ * stylesheet's responsive rules key off.
276
+ *
277
+ * In environments without `ResizeObserver` (jsdom, SSR) this stays
278
+ * `false` (wide).
279
+ */
280
+ isNarrowLayout: boolean;
281
+ /**
282
+ * Whether the sessions panel is currently open as a mobile overlay
283
+ * drawer. Only meaningful while `isNarrowLayout` is true — the wide
284
+ * layout renders the panel inline and ignores this flag. The provider
285
+ * force-closes the drawer when the layout flips back to wide and when
286
+ * the active agent or session changes (picking a chat should reveal
287
+ * the conversation, not leave the drawer covering it).
288
+ */
289
+ sessionsDrawerOpen: boolean;
290
+ /** Opens/closes the mobile sessions drawer. */
291
+ setSessionsDrawerOpen: (open: boolean) => void;
266
292
  }
267
293
 
268
294
  /**
@@ -26,6 +26,7 @@
26
26
  import {
27
27
  useCallback,
28
28
  useEffect,
29
+ useLayoutEffect,
29
30
  useMemo,
30
31
  useRef,
31
32
  useState,
@@ -172,10 +173,35 @@ export interface AIHubProviderProps {
172
173
  * the locale; omitted keys fall back to the locale, then English.
173
174
  */
174
175
  texts?: DeepPartial<AIHubTexts>;
176
+ /**
177
+ * Container width (px) below which the UI switches to the narrow
178
+ * (mobile) layout — sessions panel becomes an overlay drawer, chrome
179
+ * paddings tighten, touch targets grow. Measured against the
180
+ * component's own root element, not the viewport, so partial-width
181
+ * embeds adapt too. Defaults to `720`.
182
+ */
183
+ narrowLayoutBreakpoint?: number;
175
184
  /** The tree under the provider. */
176
185
  children: ReactNode;
177
186
  }
178
187
 
188
+ /**
189
+ * Default `narrowLayoutBreakpoint` — below this container width the
190
+ * narrow layout kicks in. 720px keeps tablets-in-portrait and phones on
191
+ * the drawer layout while desktop half-screen windows stay on the
192
+ * two-column layout.
193
+ */
194
+ const DEFAULT_NARROW_BREAKPOINT = 720;
195
+
196
+ /**
197
+ * `useLayoutEffect` that degrades to `useEffect` on the server so SSR
198
+ * renders don't warn. The narrow-layout measurement wants layout timing
199
+ * (classify before first paint → no wide-layout flash on phones), but
200
+ * must not break Next.js-style server rendering of wrapper pages.
201
+ */
202
+ const useIsomorphicLayoutEffect =
203
+ typeof window === "undefined" ? useEffect : useLayoutEffect;
204
+
179
205
  /**
180
206
  * The provider component. Single instance per ai-hub mount — multiple
181
207
  * mounts on the same page each manage their own auth state (this is the
@@ -194,6 +220,7 @@ export function AIHubProvider(props: AIHubProviderProps): ReactNode {
194
220
  fetch: fetchProp,
195
221
  onError,
196
222
  tokenStorage = "localStorage",
223
+ narrowLayoutBreakpoint = DEFAULT_NARROW_BREAKPOINT,
197
224
  } = props;
198
225
 
199
226
  // ── client ───────────────────────────────────────────────────────────
@@ -234,6 +261,41 @@ export function AIHubProvider(props: AIHubProviderProps): ReactNode {
234
261
  return readCachedLanguage() ?? defaultLanguage;
235
262
  });
236
263
 
264
+ // ── responsive layout ────────────────────────────────────────────────
265
+ // Container-width tier, observed on the provider's own root element so
266
+ // the narrow layout also kicks in for partial-width embeds (widget in a
267
+ // sidebar, iframe, …) where viewport media queries would lie. Defaults
268
+ // to wide when `ResizeObserver` is unavailable (SSR, jsdom).
269
+ const rootRef = useRef<HTMLDivElement | null>(null);
270
+ const [isNarrowLayout, setIsNarrowLayout] = useState(false);
271
+ const [sessionsDrawerOpen, setSessionsDrawerOpen] = useState(false);
272
+
273
+ useIsomorphicLayoutEffect(() => {
274
+ const el = rootRef.current;
275
+ if (!el || typeof ResizeObserver === "undefined") return undefined;
276
+ const classify = (width: number): void => {
277
+ // Width 0 means "not laid out" (display:none host, detached
278
+ // measurement) — keep the previous tier rather than flapping.
279
+ if (width <= 0) return;
280
+ setIsNarrowLayout(width < narrowLayoutBreakpoint);
281
+ };
282
+ classify(el.getBoundingClientRect().width);
283
+ const observer = new ResizeObserver((entries) => {
284
+ for (const entry of entries) {
285
+ classify(entry.contentRect.width);
286
+ }
287
+ });
288
+ observer.observe(el);
289
+ return () => observer.disconnect();
290
+ }, [narrowLayoutBreakpoint]);
291
+
292
+ // The drawer only exists in the narrow layout — flip back to wide (e.g.
293
+ // phone rotation, window resize) and the inline panel returns, so the
294
+ // overlay state must reset or it would re-open stale on the next shrink.
295
+ useEffect(() => {
296
+ if (!isNarrowLayout) setSessionsDrawerOpen(false);
297
+ }, [isNarrowLayout]);
298
+
237
299
  // ── chat state (Step 9) ─────────────────────────────────────────────
238
300
  const [agents, setAgents] = useState<AgentSummary[]>([]);
239
301
  const [agentsLoading, setAgentsLoading] = useState(false);
@@ -244,6 +306,12 @@ export function AIHubProvider(props: AIHubProviderProps): ReactNode {
244
306
  const [activeSessionId, setActiveSessionIdState] = useState<string | null>(
245
307
  null,
246
308
  );
309
+
310
+ // Picking a chat (or switching agents) should reveal the conversation —
311
+ // close the mobile drawer instead of leaving it covering the messages.
312
+ useEffect(() => {
313
+ setSessionsDrawerOpen(false);
314
+ }, [activeAgentId, activeSessionId]);
247
315
  const [messages, setMessages] = useState<AIHubMessage[]>([]);
248
316
  const [messagesLoading, setMessagesLoading] = useState(false);
249
317
  const [streamState, setStreamState] = useState<AIHubState>(() =>
@@ -1106,11 +1174,20 @@ export function AIHubProvider(props: AIHubProviderProps): ReactNode {
1106
1174
  lastTurnError,
1107
1175
  featureToggles,
1108
1176
  refreshFeatureToggles,
1177
+ isNarrowLayout,
1178
+ sessionsDrawerOpen,
1179
+ setSessionsDrawerOpen,
1109
1180
  };
1110
1181
 
1111
1182
  return (
1112
1183
  <aiHubContext.Provider value={value}>
1113
- <div className="data-club-ai-hub" data-theme={theme} lang={language}>
1184
+ <div
1185
+ ref={rootRef}
1186
+ className="data-club-ai-hub"
1187
+ data-theme={theme}
1188
+ data-layout={isNarrowLayout ? "narrow" : "wide"}
1189
+ lang={language}
1190
+ >
1114
1191
  {children}
1115
1192
  </div>
1116
1193
  </aiHubContext.Provider>
@@ -103,6 +103,12 @@ export interface UseAIHubResult {
103
103
  featureToggles: AIHubContextValue["featureToggles"];
104
104
  /** Re-fetches the toggles (used after a super-admin save). */
105
105
  refreshFeatureToggles: AIHubContextValue["refreshFeatureToggles"];
106
+ /** True while the component root is narrower than the breakpoint. */
107
+ isNarrowLayout: AIHubContextValue["isNarrowLayout"];
108
+ /** Whether the narrow-layout sessions drawer is open. */
109
+ sessionsDrawerOpen: AIHubContextValue["sessionsDrawerOpen"];
110
+ /** Opens/closes the narrow-layout sessions drawer. */
111
+ setSessionsDrawerOpen: AIHubContextValue["setSessionsDrawerOpen"];
106
112
  }
107
113
 
108
114
  /**
@@ -159,5 +165,8 @@ export function useAIHub(): UseAIHubResult {
159
165
  lastTurnError: ctx.lastTurnError,
160
166
  featureToggles: ctx.featureToggles,
161
167
  refreshFeatureToggles: ctx.refreshFeatureToggles,
168
+ isNarrowLayout: ctx.isNarrowLayout,
169
+ sessionsDrawerOpen: ctx.sessionsDrawerOpen,
170
+ setSessionsDrawerOpen: ctx.setSessionsDrawerOpen,
162
171
  };
163
172
  }
@@ -47,8 +47,12 @@ export interface AIHubAffordanceFlags {
47
47
  sessionsPanelResizable: boolean;
48
48
  /** Whether the sessions panel can be collapsed. Default true. */
49
49
  sessionsPanelCollapsible: boolean;
50
+ /** Whether the sessions panel starts collapsed on load. Default **false**. */
51
+ sessionsPanelDefaultCollapsed: boolean;
50
52
  /** Whether the "+ New chat" button renders on the panel. Default true. */
51
53
  createSessionButtonEnabled: boolean;
54
+ /** Whether the last session auto-opens on load (created when none). Default **false**. */
55
+ loadLastSessionEnabled: boolean;
52
56
  /** Whether the input area renders (read-only when false). Default true. */
53
57
  userInputEnabled: boolean;
54
58
  /** Whether the stop button is exposed while a turn is running. Default true. */
@@ -76,7 +80,9 @@ export const DEFAULT_AFFORDANCE_FLAGS: AIHubAffordanceFlags = Object.freeze({
76
80
  sessionsPanelEnabled: true,
77
81
  sessionsPanelResizable: true,
78
82
  sessionsPanelCollapsible: true,
83
+ sessionsPanelDefaultCollapsed: false,
79
84
  createSessionButtonEnabled: true,
85
+ loadLastSessionEnabled: false,
80
86
  userInputEnabled: true,
81
87
  stopButtonEnabled: true,
82
88
  toolCallsVisible: true,
@@ -100,7 +106,9 @@ export const FEATURE_TOGGLE_KEY_MAP: ReadonlyArray<
100
106
  ["sessions_panel_enabled", "sessionsPanelEnabled"],
101
107
  ["sessions_panel_resizable", "sessionsPanelResizable"],
102
108
  ["sessions_panel_collapsible", "sessionsPanelCollapsible"],
109
+ ["sessions_panel_default_collapsed", "sessionsPanelDefaultCollapsed"],
103
110
  ["create_session_button_enabled", "createSessionButtonEnabled"],
111
+ ["load_last_session_enabled", "loadLastSessionEnabled"],
104
112
  ["user_input_enabled", "userInputEnabled"],
105
113
  ["stop_button_enabled", "stopButtonEnabled"],
106
114
  ["tool_calls_visible", "toolCallsVisible"],
@@ -138,7 +146,7 @@ function resolveOne(
138
146
  }
139
147
 
140
148
  /**
141
- * Composes the three layers into a flat resolved record of all 14
149
+ * Composes the three layers into a flat resolved record of all 16
142
150
  * affordance booleans.
143
151
  *
144
152
  * Per §8.4: server toggle wins; falling through, the React prop wins;
@@ -114,6 +114,9 @@ export function MockProvider(props: {
114
114
  lastTurnError: opts.lastTurnError ?? null,
115
115
  featureToggles: opts.featureToggles ?? null,
116
116
  refreshFeatureToggles: noopAsync,
117
+ isNarrowLayout: false,
118
+ sessionsDrawerOpen: false,
119
+ setSessionsDrawerOpen: () => undefined,
117
120
  };
118
121
  return (
119
122
  <aiHubContext.Provider value={value}>{props.children}</aiHubContext.Provider>
@@ -26,6 +26,20 @@ properties only, never the attribute directly.
26
26
  .data-club-ai-hub[data-theme="dark"] { --dc-aih-bg: #0f1117; }
27
27
  ```
28
28
 
29
+ ### Layout attribute (responsive)
30
+
31
+ The provider observes the component root's **own width** (ResizeObserver,
32
+ not viewport media queries) and stamps `data-layout="narrow"` or
33
+ `data-layout="wide"` on the same root `<div>`. The breakpoint defaults to
34
+ 720px and is overridable via the provider's `narrowLayoutBreakpoint` prop.
35
+ In the narrow layout the sessions panel renders as an overlay drawer, the
36
+ chat header gains a hamburger button, paddings tighten and touch targets
37
+ grow. Wrappers can scope their own mobile brand tweaks the same way:
38
+
39
+ ```css
40
+ .data-club-ai-hub[data-layout="narrow"] .my-brand-footer { flex-direction: column; }
41
+ ```
42
+
29
43
  ## CSS custom properties (theming tokens)
30
44
 
31
45
  Per-customer wrappers override these to brand the UI; they don't fork the
@@ -204,6 +218,10 @@ block and modifier.
204
218
  | `data-club-ai-hub__layout--panel-right` | Modifier when the panel is on the right. |
205
219
  | `data-club-ai-hub__sessions-panel` | The sessions sidebar. |
206
220
  | `data-club-ai-hub__sessions-panel--collapsed` | Collapsed state (thin reveal strip). |
221
+ | `data-club-ai-hub__sessions-panel--drawer` | Narrow-layout overlay-drawer mode (replaces the inline column). |
222
+ | `data-club-ai-hub__sessions-panel--drawer-open` | Drawer slid in / visible. |
223
+ | `data-club-ai-hub__sessions-panel__scrim` | Backdrop scrim behind the open drawer (`data-open` attribute mirrors the state). |
224
+ | `data-club-ai-hub__sessions-panel__drawer-close` | Close (✕) button at the top of the drawer. |
207
225
  | `data-club-ai-hub__sessions-panel__custom-header` | Slot wrapper at the top of the panel. |
208
226
  | `data-club-ai-hub__sessions-panel__new-button` | "+ New chat" button. |
209
227
  | `data-club-ai-hub__sessions-panel__reveal` | Reveal-strip button shown in collapsed state. |
@@ -229,6 +247,7 @@ block and modifier.
229
247
  | `data-club-ai-hub__main-column` | Right side of the layout (header + body). |
230
248
  | `data-club-ai-hub__main-column__body` | Body region under the chat header. |
231
249
  | `data-club-ai-hub__chat-header` | The strip above the messages with back-link + agent name. |
250
+ | `data-club-ai-hub__chat-header__menu` | Hamburger button opening the sessions drawer (narrow layout only). |
232
251
  | `data-club-ai-hub__chat-header__back` | `[← Agents]` back-to-picker link. |
233
252
  | `data-club-ai-hub__chat-header__title` | Agent display-name text. |
234
253
  | `data-club-ai-hub__chat-header__extras` | Slot for `customHeaderContent`. |