@data-club/ai-hub 0.0.11 → 0.0.13

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 (52) hide show
  1. package/dist/auth/locales/cs.d.ts.map +1 -1
  2. package/dist/auth/locales/cs.js +5 -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 +5 -0
  6. package/dist/auth/locales/sk.js.map +1 -1
  7. package/dist/auth/texts.d.ts +13 -0
  8. package/dist/auth/texts.d.ts.map +1 -1
  9. package/dist/auth/texts.js +5 -0
  10. package/dist/auth/texts.js.map +1 -1
  11. package/dist/components/ChatShell/index.d.ts +6 -0
  12. package/dist/components/ChatShell/index.d.ts.map +1 -1
  13. package/dist/components/ChatShell/index.js +66 -3
  14. package/dist/components/ChatShell/index.js.map +1 -1
  15. package/dist/components/DataClubAIHub/index.d.ts +6 -0
  16. package/dist/components/DataClubAIHub/index.d.ts.map +1 -1
  17. package/dist/components/DataClubAIHub/index.js +6 -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 +20 -5
  21. package/dist/components/DataClubAIHubAdmin/SettingsPage.js.map +1 -1
  22. package/dist/components/MainColumn/index.d.ts +8 -0
  23. package/dist/components/MainColumn/index.d.ts.map +1 -1
  24. package/dist/components/MainColumn/index.js +40 -2
  25. package/dist/components/MainColumn/index.js.map +1 -1
  26. package/dist/components/SessionsPanel/index.d.ts +5 -0
  27. package/dist/components/SessionsPanel/index.d.ts.map +1 -1
  28. package/dist/components/SessionsPanel/index.js +8 -3
  29. package/dist/components/SessionsPanel/index.js.map +1 -1
  30. package/dist/state/affordances.d.ts +7 -1
  31. package/dist/state/affordances.d.ts.map +1 -1
  32. package/dist/state/affordances.js +7 -1
  33. package/dist/state/affordances.js.map +1 -1
  34. package/dist/types.d.ts +15 -0
  35. package/dist/types.d.ts.map +1 -1
  36. package/dist/util/exportConversation.d.ts +64 -0
  37. package/dist/util/exportConversation.d.ts.map +1 -0
  38. package/dist/util/exportConversation.js +78 -0
  39. package/dist/util/exportConversation.js.map +1 -0
  40. package/package.json +1 -1
  41. package/src/auth/locales/cs.ts +5 -0
  42. package/src/auth/locales/sk.ts +5 -0
  43. package/src/auth/texts.ts +18 -0
  44. package/src/components/ChatShell/index.tsx +67 -0
  45. package/src/components/DataClubAIHub/index.tsx +15 -0
  46. package/src/components/DataClubAIHubAdmin/SettingsPage.tsx +21 -5
  47. package/src/components/MainColumn/index.tsx +85 -1
  48. package/src/components/SessionsPanel/index.tsx +13 -3
  49. package/src/state/affordances.ts +13 -1
  50. package/src/styles/default.css +21 -0
  51. package/src/types.ts +15 -0
  52. package/src/util/exportConversation.ts +101 -0
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Conversation → Markdown export helpers.
3
+ *
4
+ * Pure functions — the `<MainColumn />` export button calls
5
+ * `buildConversationMarkdown` on the already-loaded `useAIHub().messages`
6
+ * array and hands the result to a Blob download. Keeping the serialization
7
+ * here (instead of inline in the component) makes the export rules
8
+ * unit-testable without a DOM.
9
+ *
10
+ * Export rules:
11
+ *
12
+ * - Only `user` and `assistant` messages are exported. `system` rows (the
13
+ * compiled system prompt travels in the transcript as `role: "system"`)
14
+ * and `tool` rows never appear in the file.
15
+ * - Assistant messages with no text content (tool-call-only / reasoning-only
16
+ * bubbles) are skipped — the export is the human-readable dialog, not the
17
+ * agent trace.
18
+ * - Rotating-message sessions need no special casing: the rotation runner
19
+ * deletes its own prompt's `user_message` from the Letta conversation, so
20
+ * the transcript (and therefore the export) opens with the agent's reply.
21
+ *
22
+ * @see ../components/MainColumn/index.tsx — the export button
23
+ * @see SPECIFICATIONS.md §8.4 — `export_conversation_enabled` toggle
24
+ */
25
+ import type { AIHubMessage } from "../types.js";
26
+ /** Inputs for {@link buildConversationMarkdown}. */
27
+ export interface ConversationExportArgs {
28
+ /** The session's user-supplied title; used as the document heading. */
29
+ sessionTitle: string;
30
+ /** Agent display name; rendered as the assistant speaker label. */
31
+ agentDisplayName: string;
32
+ /** Speaker label for `user` messages (localized, e.g. "You"). */
33
+ userLabel: string;
34
+ /** The transcript, chronologically sorted (as served by the provider). */
35
+ messages: ReadonlyArray<AIHubMessage>;
36
+ /** Export timestamp; injected so tests are deterministic. */
37
+ exportedAt: Date;
38
+ }
39
+ /**
40
+ * Serializes a conversation to a Markdown document.
41
+ *
42
+ * Format: an `# <title>` heading plus an "exported" byline, then one
43
+ * `## <speaker>` section per user/assistant message with the message's
44
+ * (already-markdown) content verbatim underneath.
45
+ *
46
+ * @param args - see {@link ConversationExportArgs}.
47
+ * @returns the Markdown document text (trailing newline included).
48
+ */
49
+ export declare function buildConversationMarkdown(args: ConversationExportArgs): string;
50
+ /**
51
+ * Derives a safe `.md` filename from the session title.
52
+ *
53
+ * Lowercases, strips diacritics (Czech/Slovak titles are common here),
54
+ * collapses every non-alphanumeric run to a single `-`, and appends the
55
+ * export date so repeated exports don't shadow each other in the user's
56
+ * downloads folder. Falls back to `conversation` for titles that reduce
57
+ * to nothing.
58
+ *
59
+ * @param sessionTitle - the session's title.
60
+ * @param exportedAt - export timestamp (date part is used).
61
+ * @returns e.g. `muj-rozhovor-2026-07-17.md`.
62
+ */
63
+ export declare function conversationExportFilename(sessionTitle: string, exportedAt: Date): string;
64
+ //# sourceMappingURL=exportConversation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"exportConversation.d.ts","sourceRoot":"","sources":["../../src/util/exportConversation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD,oDAAoD;AACpD,MAAM,WAAW,sBAAsB;IACrC,uEAAuE;IACvE,YAAY,EAAE,MAAM,CAAC;IACrB,mEAAmE;IACnE,gBAAgB,EAAE,MAAM,CAAC;IACzB,iEAAiE;IACjE,SAAS,EAAE,MAAM,CAAC;IAClB,0EAA0E;IAC1E,QAAQ,EAAE,aAAa,CAAC,YAAY,CAAC,CAAC;IACtC,6DAA6D;IAC7D,UAAU,EAAE,IAAI,CAAC;CAClB;AAED;;;;;;;;;GASG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,sBAAsB,GAAG,MAAM,CAqB9E;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,0BAA0B,CACxC,YAAY,EAAE,MAAM,EACpB,UAAU,EAAE,IAAI,GACf,MAAM,CAUR"}
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Conversation → Markdown export helpers.
3
+ *
4
+ * Pure functions — the `<MainColumn />` export button calls
5
+ * `buildConversationMarkdown` on the already-loaded `useAIHub().messages`
6
+ * array and hands the result to a Blob download. Keeping the serialization
7
+ * here (instead of inline in the component) makes the export rules
8
+ * unit-testable without a DOM.
9
+ *
10
+ * Export rules:
11
+ *
12
+ * - Only `user` and `assistant` messages are exported. `system` rows (the
13
+ * compiled system prompt travels in the transcript as `role: "system"`)
14
+ * and `tool` rows never appear in the file.
15
+ * - Assistant messages with no text content (tool-call-only / reasoning-only
16
+ * bubbles) are skipped — the export is the human-readable dialog, not the
17
+ * agent trace.
18
+ * - Rotating-message sessions need no special casing: the rotation runner
19
+ * deletes its own prompt's `user_message` from the Letta conversation, so
20
+ * the transcript (and therefore the export) opens with the agent's reply.
21
+ *
22
+ * @see ../components/MainColumn/index.tsx — the export button
23
+ * @see SPECIFICATIONS.md §8.4 — `export_conversation_enabled` toggle
24
+ */
25
+ /**
26
+ * Serializes a conversation to a Markdown document.
27
+ *
28
+ * Format: an `# <title>` heading plus an "exported" byline, then one
29
+ * `## <speaker>` section per user/assistant message with the message's
30
+ * (already-markdown) content verbatim underneath.
31
+ *
32
+ * @param args - see {@link ConversationExportArgs}.
33
+ * @returns the Markdown document text (trailing newline included).
34
+ */
35
+ export function buildConversationMarkdown(args) {
36
+ const lines = [];
37
+ lines.push(`# ${args.sessionTitle}`);
38
+ lines.push("");
39
+ lines.push(`> ${args.agentDisplayName} — exported ${args.exportedAt.toISOString()}`);
40
+ for (const msg of args.messages) {
41
+ if (msg.role !== "user" && msg.role !== "assistant")
42
+ continue;
43
+ const content = msg.content.trim();
44
+ if (content.length === 0)
45
+ continue;
46
+ const speaker = msg.role === "user" ? args.userLabel : args.agentDisplayName;
47
+ lines.push("");
48
+ lines.push(`## ${speaker}`);
49
+ lines.push("");
50
+ lines.push(content);
51
+ }
52
+ return `${lines.join("\n")}\n`;
53
+ }
54
+ /**
55
+ * Derives a safe `.md` filename from the session title.
56
+ *
57
+ * Lowercases, strips diacritics (Czech/Slovak titles are common here),
58
+ * collapses every non-alphanumeric run to a single `-`, and appends the
59
+ * export date so repeated exports don't shadow each other in the user's
60
+ * downloads folder. Falls back to `conversation` for titles that reduce
61
+ * to nothing.
62
+ *
63
+ * @param sessionTitle - the session's title.
64
+ * @param exportedAt - export timestamp (date part is used).
65
+ * @returns e.g. `muj-rozhovor-2026-07-17.md`.
66
+ */
67
+ export function conversationExportFilename(sessionTitle, exportedAt) {
68
+ const slug = sessionTitle
69
+ .normalize("NFKD")
70
+ .replace(/[\u0300-\u036f]/g, "")
71
+ .toLowerCase()
72
+ .replace(/[^a-z0-9]+/g, "-")
73
+ .replace(/^-+|-+$/g, "")
74
+ .slice(0, 60);
75
+ const date = exportedAt.toISOString().slice(0, 10);
76
+ return `${slug.length > 0 ? slug : "conversation"}-${date}.md`;
77
+ }
78
+ //# sourceMappingURL=exportConversation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"exportConversation.js","sourceRoot":"","sources":["../../src/util/exportConversation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAkBH;;;;;;;;;GASG;AACH,MAAM,UAAU,yBAAyB,CAAC,IAA4B;IACpE,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,KAAK,CAAC,IAAI,CAAC,KAAK,IAAI,CAAC,YAAY,EAAE,CAAC,CAAC;IACrC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACf,KAAK,CAAC,IAAI,CACR,KAAK,IAAI,CAAC,gBAAgB,eAAe,IAAI,CAAC,UAAU,CAAC,WAAW,EAAE,EAAE,CACzE,CAAC;IAEF,KAAK,MAAM,GAAG,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;QAChC,IAAI,GAAG,CAAC,IAAI,KAAK,MAAM,IAAI,GAAG,CAAC,IAAI,KAAK,WAAW;YAAE,SAAS;QAC9D,MAAM,OAAO,GAAG,GAAG,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC;QACnC,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;YAAE,SAAS;QACnC,MAAM,OAAO,GACX,GAAG,CAAC,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,gBAAgB,CAAC;QAC/D,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACf,KAAK,CAAC,IAAI,CAAC,MAAM,OAAO,EAAE,CAAC,CAAC;QAC5B,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACf,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACtB,CAAC;IAED,OAAO,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC;AACjC,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,0BAA0B,CACxC,YAAoB,EACpB,UAAgB;IAEhB,MAAM,IAAI,GAAG,YAAY;SACtB,SAAS,CAAC,MAAM,CAAC;SACjB,OAAO,CAAC,kBAAkB,EAAE,EAAE,CAAC;SAC/B,WAAW,EAAE;SACb,OAAO,CAAC,aAAa,EAAE,GAAG,CAAC;SAC3B,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC;SACvB,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IAChB,MAAM,IAAI,GAAG,UAAU,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IACnD,OAAO,GAAG,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,cAAc,IAAI,IAAI,KAAK,CAAC;AACjE,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@data-club/ai-hub",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
4
4
  "description": "React package for the data-club ai-hub: chat UI, login + change-password, admin UI, AIHub.* primitives, defineWebComponent helper, client SDK. See docs/SPECIFICATIONS.md §7.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -68,7 +68,12 @@ export const csTexts: AIHubTexts = {
68
68
  collapseSessionsPanel: "Sbalit panel konverzací",
69
69
  expandSessionsPanel: "Zobrazit panel konverzací",
70
70
  backToAgentsLink: "← Agenti",
71
+ exportConversationButton: "Export .md",
72
+ exportConversationAriaLabel: "Exportovat konverzaci jako Markdown",
73
+ exportUserLabel: "Vy",
74
+ exportAssistantFallbackLabel: "Asistent",
71
75
  untitledSessionFallback: "(bez názvu)",
76
+ autoCreatedSessionTitle: "Nová konverzace",
72
77
  noActiveSessionHint:
73
78
  "Vyberte konverzaci vlevo nebo začněte novou.",
74
79
  emptyConversationHint:
@@ -68,7 +68,12 @@ export const skTexts: AIHubTexts = {
68
68
  collapseSessionsPanel: "Zbaliť panel konverzácií",
69
69
  expandSessionsPanel: "Zobraziť panel konverzácií",
70
70
  backToAgentsLink: "← Agenti",
71
+ exportConversationButton: "Export .md",
72
+ exportConversationAriaLabel: "Exportovať konverzáciu ako Markdown",
73
+ exportUserLabel: "Vy",
74
+ exportAssistantFallbackLabel: "Asistent",
71
75
  untitledSessionFallback: "(bez názvu)",
76
+ autoCreatedSessionTitle: "Nová konverzácia",
72
77
  noActiveSessionHint:
73
78
  "Vyberte konverzáciu vľavo alebo začnite novú.",
74
79
  emptyConversationHint:
package/src/auth/texts.ts CHANGED
@@ -154,8 +154,21 @@ export interface AIHubChatTexts {
154
154
  expandSessionsPanel: string;
155
155
  /** `[← Agents]` back-to-picker link in the chat header. */
156
156
  backToAgentsLink: string;
157
+ /** Export-conversation (.md) button label in the chat header. */
158
+ exportConversationButton: string;
159
+ /** `aria-label` / tooltip on the export-conversation button. */
160
+ exportConversationAriaLabel: string;
161
+ /** Speaker heading for the user's messages in the exported Markdown. */
162
+ exportUserLabel: string;
163
+ /** Assistant speaker heading in the export when no agent name resolves. */
164
+ exportAssistantFallbackLabel: string;
157
165
  /** Fallback title for a session somehow rendered without one. */
158
166
  untitledSessionFallback: string;
167
+ /**
168
+ * Title given to the session auto-created on page load when
169
+ * `load_last_session_enabled` is on and the caller has no sessions yet.
170
+ */
171
+ autoCreatedSessionTitle: string;
159
172
  /** Body shown when no session is selected yet. */
160
173
  noActiveSessionHint: string;
161
174
  /** Body shown when the user opens a session with zero messages. */
@@ -921,7 +934,12 @@ export const defaultAIHubTexts: AIHubTexts = Object.freeze({
921
934
  collapseSessionsPanel: "Collapse sessions panel",
922
935
  expandSessionsPanel: "Show sessions panel",
923
936
  backToAgentsLink: "← Agents",
937
+ exportConversationButton: "Export .md",
938
+ exportConversationAriaLabel: "Export conversation as Markdown",
939
+ exportUserLabel: "You",
940
+ exportAssistantFallbackLabel: "Assistant",
924
941
  untitledSessionFallback: "(untitled)",
942
+ autoCreatedSessionTitle: "New chat",
925
943
  noActiveSessionHint:
926
944
  "Pick a chat from the left or start a new one to begin.",
927
945
  emptyConversationHint:
@@ -74,6 +74,10 @@ export interface ChatShellProps {
74
74
  showSessionsPanel?: boolean;
75
75
  /** sessions_panel_collapsible default. */
76
76
  sessionsPanelCollapsible?: boolean;
77
+ /** sessions_panel_default_collapsed default (false — panel starts extended). */
78
+ sessionsPanelDefaultCollapsed?: boolean;
79
+ /** load_last_session_enabled default (false — show the "pick a chat" hint). */
80
+ loadLastSession?: boolean;
77
81
  /** sessions_panel_resizable default. */
78
82
  sessionsPanelResizable?: boolean;
79
83
  /** Layout-only (not a §8.4 toggle). */
@@ -100,6 +104,8 @@ export interface ChatShellProps {
100
104
  showScrollToPresentButton?: boolean;
101
105
  /** turn_status_visible default. */
102
106
  showTurnStatus?: boolean;
107
+ /** export_conversation_enabled default. */
108
+ showExportButton?: boolean;
103
109
  /** Whether the ThemeSwitcher renders in the user menu (server override wins). */
104
110
  showThemeSwitcher?: boolean;
105
111
 
@@ -134,6 +140,10 @@ function buildPropOverrides(
134
140
  out.sessionsPanelResizable = props.sessionsPanelResizable;
135
141
  if (props.sessionsPanelCollapsible !== undefined)
136
142
  out.sessionsPanelCollapsible = props.sessionsPanelCollapsible;
143
+ if (props.sessionsPanelDefaultCollapsed !== undefined)
144
+ out.sessionsPanelDefaultCollapsed = props.sessionsPanelDefaultCollapsed;
145
+ if (props.loadLastSession !== undefined)
146
+ out.loadLastSessionEnabled = props.loadLastSession;
137
147
  if (props.showCreateSessionButton !== undefined)
138
148
  out.createSessionButtonEnabled = props.showCreateSessionButton;
139
149
  if (props.showInput !== undefined) out.userInputEnabled = props.showInput;
@@ -153,6 +163,8 @@ function buildPropOverrides(
153
163
  out.scrollToPresentVisible = props.showScrollToPresentButton;
154
164
  if (props.showTurnStatus !== undefined)
155
165
  out.turnStatusVisible = props.showTurnStatus;
166
+ if (props.showExportButton !== undefined)
167
+ out.exportConversationEnabled = props.showExportButton;
156
168
  return out;
157
169
  }
158
170
 
@@ -170,6 +182,9 @@ export function ChatShell(props: ChatShellProps): ReactNode {
170
182
  setActiveAgent,
171
183
  activeSessionId,
172
184
  setActiveSession,
185
+ sessions,
186
+ sessionsLoading,
187
+ createSession,
173
188
  featureToggles,
174
189
  } = useAIHub();
175
190
  const { deployment } = useAIHubAuth();
@@ -244,6 +259,56 @@ export function ChatShell(props: ChatShellProps): ReactNode {
244
259
  setActiveAgent,
245
260
  ]);
246
261
 
262
+ // load_last_session_enabled (§8.4): once the sessions list for the opened
263
+ // agent has actually loaded and nothing is selected, open the most
264
+ // recently updated non-archived session — or create a brand-new one when
265
+ // the caller has none — instead of showing the "pick a chat" hint.
266
+ //
267
+ // Two refs guard the flow:
268
+ // - `sessionsFetchSeenRef` — on agent open the provider still holds the
269
+ // *previous* agent's list until its fetch resolves, so we only act after
270
+ // observing a loading cycle for the current agent.
271
+ // - `autoOpenedAgentRef` — apply at most once per opened agent, so closing
272
+ // a session later doesn't snap it back open.
273
+ const sessionsFetchSeenRef = useRef(false);
274
+ const autoOpenedAgentRef = useRef<string | null>(null);
275
+ useEffect(() => {
276
+ sessionsFetchSeenRef.current = false;
277
+ if (!activeAgentId) autoOpenedAgentRef.current = null;
278
+ }, [activeAgentId]);
279
+ useEffect(() => {
280
+ if (sessionsLoading) sessionsFetchSeenRef.current = true;
281
+ }, [sessionsLoading]);
282
+ useEffect(() => {
283
+ if (!flags.loadLastSessionEnabled) return;
284
+ if (!activeAgentId || activeSessionId) return;
285
+ if (props.initialSessionId) return; // deep-link wins
286
+ if (sessionsLoading || !sessionsFetchSeenRef.current) return;
287
+ if (autoOpenedAgentRef.current === activeAgentId) return;
288
+ autoOpenedAgentRef.current = activeAgentId;
289
+ // Server orders by updated_at DESC — the first non-archived row is the
290
+ // caller's most recent session.
291
+ const last = sessions.find((s) => !s.is_archived);
292
+ if (last) {
293
+ setActiveSession(last.id);
294
+ } else {
295
+ createSession(texts.chat.autoCreatedSessionTitle).catch(() => {
296
+ // Best-effort — on failure the user still gets the manual hint.
297
+ autoOpenedAgentRef.current = null;
298
+ });
299
+ }
300
+ }, [
301
+ flags.loadLastSessionEnabled,
302
+ activeAgentId,
303
+ activeSessionId,
304
+ props.initialSessionId,
305
+ sessions,
306
+ sessionsLoading,
307
+ setActiveSession,
308
+ createSession,
309
+ texts.chat.autoCreatedSessionTitle,
310
+ ]);
311
+
247
312
  const inPicker = showAgentsPicker && !activeAgentId;
248
313
 
249
314
  const rootClass = props.className
@@ -279,6 +344,7 @@ export function ChatShell(props: ChatShellProps): ReactNode {
279
344
  <SessionsPanel
280
345
  showCreateButton={flags.createSessionButtonEnabled}
281
346
  collapsible={flags.sessionsPanelCollapsible}
347
+ defaultCollapsed={flags.sessionsPanelDefaultCollapsed}
282
348
  {...(props.texts !== undefined ? { texts: props.texts } : {})}
283
349
  {...(props.customSidebarHeader !== undefined
284
350
  ? { customHeader: props.customSidebarHeader }
@@ -293,6 +359,7 @@ export function ChatShell(props: ChatShellProps): ReactNode {
293
359
  showAgentsPicker && agentSwitcherEnabled && agents.length > 1
294
360
  }
295
361
  showTurnStatus={flags.turnStatusVisible}
362
+ showExportButton={flags.exportConversationEnabled}
296
363
  {...(props.texts !== undefined ? { texts: props.texts } : {})}
297
364
  {...(props.customHeaderContent !== undefined
298
365
  ? { customHeaderContent: props.customHeaderContent }
@@ -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. */
@@ -114,6 +118,8 @@ export interface DataClubAIHubProps {
114
118
  showScrollToPresentButton?: boolean;
115
119
  /** Default for turn_status_visible. Default true. */
116
120
  showTurnStatus?: boolean;
121
+ /** Default for export_conversation_enabled. Default true. */
122
+ showExportButton?: boolean;
117
123
 
118
124
  /* ── Theming (§7.6) ────────────────────────────────────────────── */
119
125
  /**
@@ -263,6 +269,12 @@ function AuthedShell({ props }: { props: DataClubAIHubProps }): ReactNode {
263
269
  {...(props.sessionsPanelCollapsible !== undefined
264
270
  ? { sessionsPanelCollapsible: props.sessionsPanelCollapsible }
265
271
  : {})}
272
+ {...(props.sessionsPanelDefaultCollapsed !== undefined
273
+ ? { sessionsPanelDefaultCollapsed: props.sessionsPanelDefaultCollapsed }
274
+ : {})}
275
+ {...(props.loadLastSession !== undefined
276
+ ? { loadLastSession: props.loadLastSession }
277
+ : {})}
266
278
  {...(props.sessionsPanelResizable !== undefined
267
279
  ? { sessionsPanelResizable: props.sessionsPanelResizable }
268
280
  : {})}
@@ -300,6 +312,9 @@ function AuthedShell({ props }: { props: DataClubAIHubProps }): ReactNode {
300
312
  {...(props.showTurnStatus !== undefined
301
313
  ? { showTurnStatus: props.showTurnStatus }
302
314
  : {})}
315
+ {...(props.showExportButton !== undefined
316
+ ? { showExportButton: props.showExportButton }
317
+ : {})}
303
318
  {...(props.showThemeSwitcher !== undefined
304
319
  ? { showThemeSwitcher: props.showThemeSwitcher }
305
320
  : {})}
@@ -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",
@@ -53,6 +55,7 @@ const ALL_TOGGLE_KEYS: ReadonlyArray<keyof AIHubFeatureToggles> = [
53
55
  "effort_control_visible",
54
56
  "scroll_to_present_visible",
55
57
  "turn_status_visible",
58
+ "export_conversation_enabled",
56
59
  ];
57
60
 
58
61
  /** Toggles grouped by the §8.4 visual grouping (purely organizational). */
@@ -66,7 +69,9 @@ const TOGGLE_GROUPS: ReadonlyArray<{
66
69
  "sessions_panel_enabled",
67
70
  "sessions_panel_resizable",
68
71
  "sessions_panel_collapsible",
72
+ "sessions_panel_default_collapsed",
69
73
  "create_session_button_enabled",
74
+ "load_last_session_enabled",
70
75
  ],
71
76
  },
72
77
  {
@@ -83,13 +88,24 @@ const TOGGLE_GROUPS: ReadonlyArray<{
83
88
  "tpm_notice_visible",
84
89
  "scroll_to_present_visible",
85
90
  "turn_status_visible",
91
+ "export_conversation_enabled",
86
92
  ],
87
93
  },
88
94
  ];
89
95
 
90
- /** Toggle defaults — every toggle is on by default per §8.4. */
91
- function defaultToggleValue(): boolean {
92
- 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);
93
109
  }
94
110
 
95
111
  /** Format ISO date as `<localized date> (<localized N days ago>)`. */
@@ -614,12 +630,12 @@ function FeatureTogglesSection(props: FeatureTogglesSectionProps): ReactNode {
614
630
  // The server's blob may omit keys (meaning "fall through to defaults"). The
615
631
  // UI shows the effective value for the user — fill in defaults for any
616
632
  // missing key. Save only sends the keys the user actually has toggled (in
617
- // our case all of them, since the blob always carries all 13 once saved).
633
+ // our case all of them, since the blob always carries all 16 once saved).
618
634
  const [values, setValues] = useState<Record<string, boolean>>(() => {
619
635
  const initial: Record<string, boolean> = {};
620
636
  for (const key of ALL_TOGGLE_KEYS) {
621
637
  const fromServer = props.settings.feature_toggles[key];
622
- initial[key] = typeof fromServer === "boolean" ? fromServer : defaultToggleValue();
638
+ initial[key] = typeof fromServer === "boolean" ? fromServer : defaultToggleValue(key);
623
639
  }
624
640
  return initial;
625
641
  });
@@ -22,6 +22,10 @@ import {
22
22
  } from "../../auth/texts.js";
23
23
  import { useResolvedTexts } from "../../context/useAIHubTexts.js";
24
24
  import { useAIHub } from "../../context/useAIHub.js";
25
+ import {
26
+ buildConversationMarkdown,
27
+ conversationExportFilename,
28
+ } from "../../util/exportConversation.js";
25
29
 
26
30
  import { TurnStatusIndicator } from "../TurnStatusIndicator/index.js";
27
31
 
@@ -37,6 +41,8 @@ export interface MainColumnClassNames {
37
41
  back: string;
38
42
  /** Agent display-name text. */
39
43
  title: string;
44
+ /** Export-conversation (.md) button. */
45
+ export: string;
40
46
  /** Wrapper for slot content on the right side of the header. */
41
47
  extras: string;
42
48
  /** Children container (messages + input live here). */
@@ -48,6 +54,7 @@ const DEFAULT_CLASS_NAMES: MainColumnClassNames = Object.freeze({
48
54
  header: "data-club-ai-hub__chat-header",
49
55
  back: "data-club-ai-hub__chat-header__back",
50
56
  title: "data-club-ai-hub__chat-header__title",
57
+ export: "data-club-ai-hub__chat-header__export",
51
58
  extras: "data-club-ai-hub__chat-header__extras",
52
59
  body: "data-club-ai-hub__main-column__body",
53
60
  }) as MainColumnClassNames;
@@ -68,6 +75,12 @@ export interface MainColumnProps {
68
75
  * default). Defaults to true.
69
76
  */
70
77
  showTurnStatus?: boolean;
78
+ /**
79
+ * Whether the export-conversation (.md) button renders in the chat
80
+ * header. Receives the resolved §8.4 `export_conversation_enabled`
81
+ * flag. Defaults to true.
82
+ */
83
+ showExportButton?: boolean;
71
84
  /** Slot rendered on the right side of the chat header. */
72
85
  customHeaderContent?: ReactNode;
73
86
  /** The children — `<MessageList />` + `<MessageInput />` (or whatever). */
@@ -87,17 +100,75 @@ export interface MainColumnProps {
87
100
  * @returns the rendered column.
88
101
  */
89
102
  export function MainColumn(props: MainColumnProps): ReactNode {
90
- const { agents, activeAgentId, setActiveAgent } = useAIHub();
103
+ const {
104
+ agents,
105
+ activeAgentId,
106
+ setActiveAgent,
107
+ sessions,
108
+ activeSessionId,
109
+ messages,
110
+ } = useAIHub();
91
111
  const texts = useResolvedTexts(props.texts);
92
112
  const cls = { ...DEFAULT_CLASS_NAMES, ...(props.classNames ?? {}) };
93
113
  const showBack = props.showBackToAgents ?? true;
94
114
  const showTurnStatus = props.showTurnStatus ?? true;
115
+ const showExportButton = props.showExportButton ?? true;
95
116
 
96
117
  const activeAgent = useMemo(
97
118
  () => agents.find((a) => a.id === activeAgentId) ?? null,
98
119
  [agents, activeAgentId],
99
120
  );
100
121
 
122
+ const activeSession = useMemo(
123
+ () => (sessions ?? []).find((s) => s.id === activeSessionId) ?? null,
124
+ [sessions, activeSessionId],
125
+ );
126
+
127
+ // Whether the export button has anything to export — at least one
128
+ // user/assistant message with text content (mirrors the serializer's
129
+ // skip rules so we never download an empty document).
130
+ const hasExportableMessages = useMemo(
131
+ () =>
132
+ (messages ?? []).some(
133
+ (m) =>
134
+ (m.role === "user" || m.role === "assistant") &&
135
+ m.content.trim().length > 0,
136
+ ),
137
+ [messages],
138
+ );
139
+
140
+ /**
141
+ * Serializes the visible conversation to Markdown and triggers a
142
+ * client-side download. System-prompt rows (`role: "system"`) and tool
143
+ * rows are excluded by the serializer; rotating-message sessions open
144
+ * with the agent's reply because the rotation runner already deleted
145
+ * its own prompt server-side.
146
+ */
147
+ function handleExport(): void {
148
+ const exportedAt = new Date();
149
+ const sessionTitle =
150
+ activeSession?.title && activeSession.title.trim().length > 0
151
+ ? activeSession.title
152
+ : texts.chat.untitledSessionFallback;
153
+ const markdown = buildConversationMarkdown({
154
+ sessionTitle,
155
+ agentDisplayName:
156
+ activeAgent?.display_name ?? texts.chat.exportAssistantFallbackLabel,
157
+ userLabel: texts.chat.exportUserLabel,
158
+ messages: messages ?? [],
159
+ exportedAt,
160
+ });
161
+ const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" });
162
+ const url = URL.createObjectURL(blob);
163
+ const anchor = document.createElement("a");
164
+ anchor.href = url;
165
+ anchor.download = conversationExportFilename(sessionTitle, exportedAt);
166
+ document.body.appendChild(anchor);
167
+ anchor.click();
168
+ anchor.remove();
169
+ URL.revokeObjectURL(url);
170
+ }
171
+
101
172
  const rootClass = props.className
102
173
  ? `${cls.root} ${props.className}`
103
174
  : cls.root;
@@ -119,6 +190,19 @@ export function MainColumn(props: MainColumnProps): ReactNode {
119
190
  {activeAgent?.display_name ?? ""}
120
191
  </h2>
121
192
  {showTurnStatus ? <TurnStatusIndicator /> : null}
193
+ {showExportButton && activeSessionId !== null ? (
194
+ <button
195
+ type="button"
196
+ className={cls.export}
197
+ onClick={handleExport}
198
+ disabled={!hasExportableMessages}
199
+ aria-label={texts.chat.exportConversationAriaLabel}
200
+ title={texts.chat.exportConversationAriaLabel}
201
+ data-testid="chat-header-export"
202
+ >
203
+ {texts.chat.exportConversationButton}
204
+ </button>
205
+ ) : null}
122
206
  {props.customHeaderContent ? (
123
207
  <div className={cls.extras} data-testid="chat-header-extras">
124
208
  {props.customHeaderContent}
@@ -114,6 +114,11 @@ export interface SessionsPanelProps {
114
114
  showCreateButton?: boolean;
115
115
  /** Whether the panel can be collapsed. Defaults to true. */
116
116
  collapsible?: boolean;
117
+ /**
118
+ * Whether the panel starts collapsed (until the user toggles it).
119
+ * Defaults to false — panel starts extended.
120
+ */
121
+ defaultCollapsed?: boolean;
117
122
  /** Top-of-panel slot. */
118
123
  customHeader?: ReactNode;
119
124
  /** Bottom-of-panel slot, rendered above the archived-filter toggle. */
@@ -149,7 +154,12 @@ export function SessionsPanel(props: SessionsPanelProps): ReactNode {
149
154
  const showCreate = props.showCreateButton ?? true;
150
155
  const collapsible = props.collapsible ?? true;
151
156
 
152
- const [collapsed, setCollapsed] = useState(false);
157
+ // `null` = the user hasn't toggled yet — follow `defaultCollapsed`. This
158
+ // matters because the server's feature-toggle blob resolves async: a
159
+ // late-arriving `sessions_panel_default_collapsed=true` still applies as
160
+ // long as the user hasn't made an explicit choice.
161
+ const [userCollapsed, setUserCollapsed] = useState<boolean | null>(null);
162
+ const collapsed = userCollapsed ?? props.defaultCollapsed ?? false;
153
163
  const [modalOpen, setModalOpen] = useState(false);
154
164
  const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
155
165
 
@@ -176,7 +186,7 @@ export function SessionsPanel(props: SessionsPanelProps): ReactNode {
176
186
  <button
177
187
  type="button"
178
188
  className={cls.reveal}
179
- onClick={() => setCollapsed(false)}
189
+ onClick={() => setUserCollapsed(false)}
180
190
  aria-label={texts.chat.expandSessionsPanel}
181
191
  data-testid="sessions-panel-reveal"
182
192
  >
@@ -243,7 +253,7 @@ export function SessionsPanel(props: SessionsPanelProps): ReactNode {
243
253
  <button
244
254
  type="button"
245
255
  className={cls.collapseToggle}
246
- onClick={() => setCollapsed(true)}
256
+ onClick={() => setUserCollapsed(true)}
247
257
  aria-label={texts.chat.collapseSessionsPanel}
248
258
  data-testid="sessions-panel-collapse"
249
259
  >