From fc2060de7dc3421d0edcd8bd4b823aa17b4f3d4b Mon Sep 17 00:00:00 2001 From: tenkalden Date: Thu, 18 Jun 2026 21:59:09 +0530 Subject: [PATCH 1/3] feat: add per-section AI editor sidebar with streaming chat and apply functionality Add a new "Edit with SherabAI" feature that allows editing individual course sections through an AI-powered sidebar. Display a banner above populated section lists with a sparkle button to open the editor. Implement streaming section chat via SSE with support for message editing, draft change proposals, and applying edits. Add API endpoints for section-specific chat, applying section edits, and fetching section content --- .../SectionEditorSidebar.tsx | 470 ++++++++++++++++++ src/ai-course-creator/data/api.ts | 142 ++++++ src/ai-course-creator/index.ts | 1 + src/ai-course-creator/messages.ts | 71 +++ src/ai-course-creator/utils.ts | 21 + src/course-outline/CourseOutline.tsx | 49 +- 6 files changed, 753 insertions(+), 1 deletion(-) create mode 100644 src/ai-course-creator/SectionEditorSidebar.tsx diff --git a/src/ai-course-creator/SectionEditorSidebar.tsx b/src/ai-course-creator/SectionEditorSidebar.tsx new file mode 100644 index 0000000000..eed48c86da --- /dev/null +++ b/src/ai-course-creator/SectionEditorSidebar.tsx @@ -0,0 +1,470 @@ +import { + useCallback, useEffect, useRef, useState, +} from 'react'; +import { createPortal } from 'react-dom'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Alert, Button, Form, Icon, IconButton, +} from '@openedx/paragon'; +import { + AutoAwesome as SparkleIcon, + Close as CloseIcon, + Refresh as RefreshIcon, + Send as SendIcon, +} from '@openedx/paragon/icons'; + +import ChatMessageBubble from './ChatMessageBubble'; +import { + applySectionEdits, getSectionSession, resetSectionSession, streamSectionChat, + type ChatMessageData, +} from './data/api'; +import { stripPhaseMarker, stripSectionEdits } from './utils'; +import messages from './messages'; + +interface SectionOption { + usageKey: string; + displayName: string; +} + +interface Props { + courseId: string; + isOpen: boolean; + sections: SectionOption[]; + onClose: () => void; + onApplied: (summary: { + sectionName: string; + counts: { updated: number; created: number; deleted: number; reordered: number }; + }) => void; +} + +// Strip the SECTION_EDITS block FIRST: stripPhaseMarker's greedy leading-marker +// regex would otherwise eat the "===SECTION_EDITS_START===" opener and leave the +// raw JSON behind. +const cleanForDisplay = (text: string) => stripPhaseMarker(stripSectionEdits(text)); + +/** + * Right-side drawer that edits ONE section conversationally. The creator picks a + * section, chats with Sherab (themed on the section's zero-to-hero arc), and + * commits the proposed edits with the Apply button. Each section keeps its own + * thread so switching the dropdown is non-destructive. + */ +const SectionEditorSidebar = ({ + courseId, isOpen, sections, onClose, onApplied, +}: Props) => { + const intl = useIntl(); + const [selectedKey, setSelectedKey] = useState(''); + const [chatBySection, setChatBySection] = useState>({}); + const [canApplyBySection, setCanApplyBySection] = useState>({}); + const [inputValue, setInputValue] = useState(''); + const [streamingText, setStreamingText] = useState(''); + const [isStreaming, setIsStreaming] = useState(false); + const [isLoadingThread, setIsLoadingThread] = useState(false); + const [isApplying, setIsApplying] = useState(false); + const [error, setError] = useState(''); + + const threadEndRef = useRef(null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const textareaRef = useRef(null); + // Sections we've already loaded/initialised this page session, so re-renders + // don't re-fetch or fire duplicate openers. + const loadedRef = useRef>(new Set()); + + const chatMessages = selectedKey ? (chatBySection[selectedKey] || []) : []; + const canApply = selectedKey ? Boolean(canApplyBySection[selectedKey]) : false; + const selectedSection = sections.find((s) => s.usageKey === selectedKey); + + const appendMessage = useCallback((key: string, message: ChatMessageData) => { + setChatBySection((prev) => ({ ...prev, [key]: [...(prev[key] || []), message] })); + }, []); + + const runChat = useCallback(async (sectionKey: string, message: string, opts: { editMessageId?: number } = {}) => { + setError(''); + setCanApplyBySection((prev) => ({ ...prev, [sectionKey]: false })); + setIsStreaming(true); + setStreamingText(''); + + const tagUserMessage = (id: number) => setChatBySection((prev) => { + const thread = [...(prev[sectionKey] || [])]; + for (let i = thread.length - 1; i >= 0; i -= 1) { + if (thread[i].role === 'user' && thread[i].id === undefined) { + thread[i] = { ...thread[i], id }; + break; + } + } + return { ...prev, [sectionKey]: thread }; + }); + + try { + const { + fullText, canApply: applyable, assistantMessageId, + } = await streamSectionChat(courseId, sectionKey, message, { + editMessageId: opts.editMessageId, + onToken: (text) => setStreamingText((prev) => prev + text), + onUserMessageId: tagUserMessage, + }); + appendMessage(sectionKey, { + role: 'assistant', + content: cleanForDisplay(fullText), + id: assistantMessageId, + }); + setCanApplyBySection((prev) => ({ ...prev, [sectionKey]: applyable })); + } catch (e) { + const detail = e instanceof Error && e.message ? e.message : ''; + setError(detail || intl.formatMessage(messages.genericError)); + } finally { + setIsStreaming(false); + setStreamingText(''); + } + }, [courseId, intl, appendMessage]); + + // Initialise a section the first time it's opened: resume its saved + // conversation if there is one, otherwise greet (backend seeds the + // zero-to-hero framing). + const initSection = useCallback(async (sectionKey: string) => { + if (loadedRef.current.has(sectionKey)) { return; } + loadedRef.current.add(sectionKey); + setIsLoadingThread(true); + try { + const session = await getSectionSession(courseId, sectionKey); + if (session.messages && session.messages.length) { + setChatBySection((prev) => ({ + ...prev, + [sectionKey]: session.messages.map((m) => ({ id: m.id, role: m.role, content: m.content })), + })); + setCanApplyBySection((prev) => ({ ...prev, [sectionKey]: Boolean(session.canApply) })); + setIsLoadingThread(false); + } else { + setIsLoadingThread(false); + runChat(sectionKey, ''); + } + } catch (e) { + // If resume fails, fall back to greeting so the section is still usable. + setIsLoadingThread(false); + runChat(sectionKey, ''); + } + }, [courseId, runChat]); + + useEffect(() => { + if (isOpen && selectedKey) { initSection(selectedKey); } + }, [isOpen, selectedKey, initSection]); + + useEffect(() => { + threadEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [chatMessages, streamingText]); + + useEffect(() => { + if (isOpen && selectedKey && !isStreaming && !isApplying) { + textareaRef.current?.focus(); + } + }, [isOpen, selectedKey, isStreaming, isApplying]); + + const resetTextareaHeight = () => { + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + textareaRef.current.style.overflowY = 'hidden'; + } + }; + + const handleSend = () => { + const message = inputValue.trim(); + if (!message || isStreaming || !selectedKey) { return; } + appendMessage(selectedKey, { role: 'user', content: message }); + setInputValue(''); + resetTextareaHeight(); + runChat(selectedKey, message); + }; + + const handleEditMessage = (index: number, newContent: string) => { + const target = chatMessages[index]; + const text = newContent.trim(); + if (!target || target.role !== 'user' || target.id === undefined) { return; } + if (!text || isStreaming || isApplying || !selectedKey) { return; } + setChatBySection((prev) => ({ + ...prev, + [selectedKey]: [...(prev[selectedKey] || []).slice(0, index), { role: 'user', content: text }], + })); + runChat(selectedKey, text, { editMessageId: target.id }); + }; + + const handleSelectSection = (key: string) => { + setSelectedKey(key); + setError(''); + setInputValue(''); + setStreamingText(''); + }; + + const handleApply = async () => { + if (!canApply || isApplying || !selectedKey) { return; } + setIsApplying(true); + setError(''); + try { + const result = await applySectionEdits(courseId, selectedKey); + setCanApplyBySection((prev) => ({ ...prev, [selectedKey]: false })); + onApplied({ sectionName: result.sectionName, counts: result.counts }); + } catch (e) { + const detail = e instanceof Error && e.message ? e.message : ''; + setError(detail || intl.formatMessage(messages.genericError)); + } finally { + setIsApplying(false); + } + }; + + // Clear the current section's conversation and greet fresh. + const handleStartOver = async () => { + if (!selectedKey || isStreaming || isApplying) { return; } + setError(''); + setCanApplyBySection((prev) => ({ ...prev, [selectedKey]: false })); + try { + await resetSectionSession(courseId, selectedKey); + } catch (e) { + // Even if the server delete fails, clear the UI so the user can restart. + } + setChatBySection((prev) => ({ ...prev, [selectedKey]: [] })); + runChat(selectedKey, ''); + }; + + // Close on Escape key + useEffect(() => { + if (!isOpen) { return undefined; } + const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { onClose(); } }; + document.addEventListener('keydown', handleKey); + return () => document.removeEventListener('keydown', handleKey); + }, [isOpen, onClose]); + + if (!isOpen) { return null; } + + const panel = ( + <> + + + {/* Backdrop */} +
+ + {/* Panel */} +
+ + {/* Header */} +
+
+ + + + {intl.formatMessage(messages.sectionEditorTitle)} +
+ +
+ + {/* Section picker */} +
+ + {intl.formatMessage(messages.sectionSelectLabel)} + +
+ handleSelectSection(e.target.value)} + style={{ flex: 1 }} + > + + {sections.map((s) => ( + + ))} + + {selectedKey && ( + + )} +
+
+ + {!selectedKey ? ( +
+ + + +

{intl.formatMessage(messages.sectionSelectHint)}

+
+ ) : ( + <> + {error && ( +
+ setError('')} dismissible>{error} +
+ )} + + {/* Thread — scrollable outer, inner spacer pins messages to bottom */} +
+
+ {/* Spacer pushes messages to the bottom when thread is short */} +
+ {isLoadingThread && chatMessages.length === 0 && ( +
+
+ )} + {chatMessages.map((message, idx) => ( + handleEditMessage(idx, newContent)} + /> + ))} + {isStreaming && ( + streamingText + ? + : ( +
+
+ ) + )} +
+
+
+ + {/* Composer + apply footer */} +
+
+ setInputValue(e.target.value)} + onInput={(e) => { + const el = e.currentTarget as HTMLTextAreaElement; + el.style.height = 'auto'; + const maxH = 120; + el.style.height = `${Math.min(el.scrollHeight, maxH)}px`; + el.style.overflowY = el.scrollHeight > maxH ? 'auto' : 'hidden'; + }} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }} + placeholder={intl.formatMessage(messages.inputPlaceholder)} + disabled={isStreaming || isApplying} + style={{ + resize: 'none', + overflowY: 'hidden', + minHeight: '1.75rem', + border: 'none', + boxShadow: 'none', + padding: '0.125rem 0', + flex: 1, + background: 'transparent', + }} + /> + +
+ +
+ {intl.formatMessage(messages.sectionApplyHint)} + +
+
+ + )} +
+ + ); + + return createPortal(panel, document.body); +}; + +export default SectionEditorSidebar; diff --git a/src/ai-course-creator/data/api.ts b/src/ai-course-creator/data/api.ts index 79d808b3ac..2e8393d9c7 100644 --- a/src/ai-course-creator/data/api.ts +++ b/src/ai-course-creator/data/api.ts @@ -9,6 +9,9 @@ export const getGenerateUrl = () => `${getApiBaseUrl()}/api/ai-course-creator/ge export const getConfigUrl = () => `${getApiBaseUrl()}/api/ai-course-creator/config/`; export const getSessionUrl = (courseId: string) => `${getApiBaseUrl()}/api/ai-course-creator/session/?course_id=${encodeURIComponent(courseId)}`; export const getMaterialUrl = (id: number) => `${getApiBaseUrl()}/api/ai-course-creator/material/${id}/`; +export const getSectionChatUrl = () => `${getApiBaseUrl()}/api/ai-course-creator/section-chat/`; +export const getApplySectionUrl = () => `${getApiBaseUrl()}/api/ai-course-creator/apply-section/`; +export const getSectionContentUrl = (courseId: string, sectionLocator: string) => `${getApiBaseUrl()}/api/ai-course-creator/section-content/?course_id=${encodeURIComponent(courseId)}§ion_locator=${encodeURIComponent(sectionLocator)}`; export interface ChatMessageData { id?: number; @@ -26,10 +29,12 @@ export interface MaterialData { export interface SessionData { id: number; courseId: string; + sectionLocator?: string; status: string; messages: ChatMessageData[]; materials: MaterialData[]; hasCourseJson: boolean; + canApply?: boolean; currentPhase: number; generationStatus: string; generationError: string; @@ -42,6 +47,20 @@ export interface GenerateCounts { components: number; } +export interface SectionApplyCounts { + updated: number; + created: number; + deleted: number; + reordered: number; +} + +export interface SectionApplyResult { + sectionName: string; + counts: SectionApplyCounts; + rejected: Array<{ target: string; reason: string }>; + errors: Array<{ target: string; reason: string }>; +} + /** Read the Django CSRF token from cookies (only works same-domain). */ const getCsrfTokenFromCookie = (): string => { const match = document.cookie.match(/(?:^|;\s*)csrftoken=([^;]+)/); @@ -273,7 +292,130 @@ export async function resetSession(courseId: string): Promise { await getAuthenticatedHttpClient().delete(getSessionUrl(courseId)); } +/** Reset just one section's editor conversation. */ +export async function resetSectionSession(courseId: string, sectionLocator: string): Promise { + const url = `${getSessionUrl(courseId)}§ion_locator=${encodeURIComponent(sectionLocator)}`; + await getAuthenticatedHttpClient().delete(url); +} + +/** Fetch one section's saved editor conversation so the sidebar can resume it. */ +export async function getSectionSession(courseId: string, sectionLocator: string): Promise { + const url = `${getSessionUrl(courseId)}§ion_locator=${encodeURIComponent(sectionLocator)}`; + const { data } = await getAuthenticatedHttpClient().get(url); + return camelCaseObject(data); +} + /** Delete a single uploaded material. */ export async function deleteMaterial(id: number): Promise { await getAuthenticatedHttpClient().delete(getMaterialUrl(id)); } + +/** + * Stream a per-section editor reply via SSE. + * + * Mirrors `streamChat`, scoped to one section (`sectionLocator`). The `done` + * frame reports `canApply` when the assistant has produced changes that the + * user can commit via `applySectionEdits`. + */ +export async function streamSectionChat( + courseId: string, + sectionLocator: string, + message: string, + { + onToken, signal, editMessageId, onUserMessageId, + }: { + onToken: (text: string) => void; + signal?: AbortSignal; + editMessageId?: number; + onUserMessageId?: (id: number) => void; + }, +): Promise<{ + fullText: string; + canApply: boolean; + userMessageId?: number; + assistantMessageId?: number; + }> { + const csrfToken = await resolveCsrfToken(); + const response = await fetch(getSectionChatUrl(), { + method: 'POST', + credentials: 'include', + signal, + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken, + }, + body: JSON.stringify({ + course_id: courseId, + section_locator: sectionLocator, + message, + ...(editMessageId !== undefined ? { edit_message_id: editMessageId } : {}), + }), + }); + + if (!response.ok || !response.body) { + throw new Error(`Section chat request failed (${response.status})`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let fullText = ''; + let canApply = false; + let userMessageId: number | undefined; + let assistantMessageId: number | undefined; + + const handleFrame = (frame: string) => { + const line = frame.split('\n').find((l) => l.startsWith('data:')); + if (!line) { return; } + try { + const payload = JSON.parse(line.slice(5).trim()); + if (payload.type === 'meta') { + if (payload.userMessageId) { + userMessageId = payload.userMessageId; + onUserMessageId?.(payload.userMessageId); + } + } else if (payload.type === 'token') { + fullText += payload.text; + onToken(payload.text); + } else if (payload.type === 'done') { + canApply = Boolean(payload.canApply); + if (payload.userMessageId) { userMessageId = payload.userMessageId; } + if (payload.assistantMessageId) { assistantMessageId = payload.assistantMessageId; } + } else if (payload.type === 'error') { + throw new Error(payload.error); + } + } catch (e) { + if (e instanceof Error && e.message && !e.message.includes('JSON')) { + throw e; + } + } + }; + + // eslint-disable-next-line no-constant-condition + while (true) { + // eslint-disable-next-line no-await-in-loop + const { value, done } = await reader.read(); + if (done) { break; } + buffer += decoder.decode(value, { stream: true }); + const frames = buffer.split('\n\n'); + buffer = frames.pop() || ''; + frames.forEach(handleFrame); + } + if (buffer.trim()) { handleFrame(buffer); } + + return { + fullText, canApply, userMessageId, assistantMessageId, + }; +} + +/** Apply the most recently proposed edits for a section. */ +export async function applySectionEdits( + courseId: string, + sectionLocator: string, +): Promise { + const { data } = await getAuthenticatedHttpClient().post(getApplySectionUrl(), { + course_id: courseId, + section_locator: sectionLocator, + }); + return camelCaseObject(data); +} diff --git a/src/ai-course-creator/index.ts b/src/ai-course-creator/index.ts index 66ae6dfbcc..8721244fd8 100644 --- a/src/ai-course-creator/index.ts +++ b/src/ai-course-creator/index.ts @@ -1 +1,2 @@ export { default as AiCourseCreatorModal } from './AiCourseCreatorModal'; +export { default as SectionEditorSidebar } from './SectionEditorSidebar'; diff --git a/src/ai-course-creator/messages.ts b/src/ai-course-creator/messages.ts index 93c713d32e..8284b58972 100644 --- a/src/ai-course-creator/messages.ts +++ b/src/ai-course-creator/messages.ts @@ -171,6 +171,77 @@ const messages = defineMessages({ defaultMessage: 'Generate', description: 'Label for phase 4 (Course Generation) in the phase indicator', }, + // --- Per-section editor ("Edit with SherabAI") --- + sectionBannerTitle: { + id: 'course-authoring.ai-course-creator.section.banner.title', + defaultMessage: 'Optimize your course with AI', + description: 'Title of the banner above the section list that opens the section editor', + }, + sectionBannerSubtitle: { + id: 'course-authoring.ai-course-creator.section.banner.subtitle', + defaultMessage: 'Generate objectives, simplify content, or get custom help for your sections.', + description: 'Subtitle of the section-editor banner', + }, + sectionEditButton: { + id: 'course-authoring.ai-course-creator.section.edit.button', + defaultMessage: 'Edit with SherabAI', + description: 'Button that opens the per-section AI editor sidebar', + }, + sectionEditorTitle: { + id: 'course-authoring.ai-course-creator.section.editor.title', + defaultMessage: 'Edit with SherabAI', + description: 'Title of the section editor sidebar', + }, + sectionSelectLabel: { + id: 'course-authoring.ai-course-creator.section.select.label', + defaultMessage: 'Choose a section to edit', + description: 'Label for the section dropdown in the editor sidebar', + }, + sectionSelectPlaceholder: { + id: 'course-authoring.ai-course-creator.section.select.placeholder', + defaultMessage: 'Select a section…', + description: 'Placeholder option in the section dropdown', + }, + sectionSelectHint: { + id: 'course-authoring.ai-course-creator.section.select.hint', + defaultMessage: 'Pick a section above to start editing it with Sherab.', + description: 'Hint shown before a section is selected', + }, + zeroToHeroHeading: { + id: 'course-authoring.ai-course-creator.section.zero-to-hero', + defaultMessage: 'Zero-to-hero: {sectionName}', + description: 'Sub-heading shown above the chat once a section is selected', + }, + sectionApplyButton: { + id: 'course-authoring.ai-course-creator.section.apply.button', + defaultMessage: 'Apply changes', + description: 'Button that commits the proposed section edits', + }, + sectionApplyHint: { + id: 'course-authoring.ai-course-creator.section.apply.hint', + defaultMessage: 'Chat to propose changes, then apply them.', + description: 'Helper text shown next to the apply button before changes are ready', + }, + sectionApplyReady: { + id: 'course-authoring.ai-course-creator.section.apply.ready', + defaultMessage: 'Changes ready to apply (saved as draft).', + description: 'Helper text shown when the assistant has proposed applyable changes', + }, + sectionApplying: { + id: 'course-authoring.ai-course-creator.section.applying', + defaultMessage: 'Applying…', + description: 'Shown on the apply button while edits are being committed', + }, + sectionApplySuccess: { + id: 'course-authoring.ai-course-creator.section.apply.success', + defaultMessage: 'Updated “{sectionName}”: {updated} edited, {created} added, {deleted} removed.', + description: 'Toast after section edits are applied', + }, + closeSidebar: { + id: 'course-authoring.ai-course-creator.section.close', + defaultMessage: 'Close', + description: 'Accessible label for the sidebar close button', + }, }); export default messages; diff --git a/src/ai-course-creator/utils.ts b/src/ai-course-creator/utils.ts index 97ba5267fe..d15ec8b8d0 100644 --- a/src/ai-course-creator/utils.ts +++ b/src/ai-course-creator/utils.ts @@ -1,6 +1,9 @@ export const COURSE_JSON_START = '===COURSE_JSON_START==='; export const COURSE_JSON_END = '===COURSE_JSON_END==='; +export const SECTION_EDITS_START = '===SECTION_EDITS_START==='; +export const SECTION_EDITS_END = '===SECTION_EDITS_END==='; + export const PHASE_MARKER_RE = /===SHERAB_PHASE:\d+===/g; // Matches a complete OR partially-streamed marker at the very start of a message, @@ -29,3 +32,21 @@ export const stripCourseJson = (text: string): string => { const after = endIdx === -1 ? '' : text.slice(endIdx + COURSE_JSON_END.length); return (before + after).trim(); }; + +/** + * Remove the machine-readable SECTION_EDITS block from a per-section editor + * reply. Handles the still-open case while streaming (drops everything from the + * opening marker on, plus a partially-typed opening marker at the tail) so the + * raw JSON never flashes on screen. + */ +export const stripSectionEdits = (text: string): string => { + const startIdx = text.indexOf(SECTION_EDITS_START); + if (startIdx !== -1) { + const before = text.slice(0, startIdx); + const endIdx = text.indexOf(SECTION_EDITS_END); + const after = endIdx === -1 ? '' : text.slice(endIdx + SECTION_EDITS_END.length); + return (before + after).trim(); + } + // Hide a partial opening marker as it streams in (e.g. "===SECTION_ED"). + return text.replace(/===S?E?C?T?I?O?N?_?E?D?I?T?S?_?S?T?A?R?T?=*$/, '').trim(); +}; diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index c33da507e5..b5927923dd 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Button, + Card, Container, Layout, Row, @@ -38,7 +39,7 @@ import { ContentType } from '@src/library-authoring/routes'; import { NOTIFICATION_MESSAGES } from '@src/constants'; import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants'; import { XBlock } from '@src/data/types'; -import { AiCourseCreatorModal } from '@src/ai-course-creator'; +import { AiCourseCreatorModal, SectionEditorSidebar } from '@src/ai-course-creator'; import { getAiConfig } from '@src/ai-course-creator/data/api'; import aiMessages from '@src/ai-course-creator/messages'; import { @@ -177,6 +178,23 @@ const CourseOutline = ({ courseId }: CourseOutlineProps) => { dispatch(fetchCourseOutlineIndexQuery(courseId)); }, [dispatch, courseId, intl]); + // Per-section AI editor ("Edit with SherabAI"), shown on a populated outline. + const [isSectionEditorOpen, setIsSectionEditorOpen] = useState(false); + const handleSectionApplied = useCallback((summary: { + sectionName: string; + counts: { updated: number; created: number; deleted: number; reordered: number }; + }) => { + setIsSectionEditorOpen(false); + setToastMessage(intl.formatMessage(aiMessages.sectionApplySuccess, { + sectionName: summary.sectionName, + updated: summary.counts.updated, + created: summary.counts.created, + deleted: summary.counts.deleted, + })); + // Reload the outline so the edited section reflects the changes. + dispatch(fetchCourseOutlineIndexQuery(courseId)); + }, [dispatch, courseId, intl]); + useEffect(() => { // Wait for the course data to load before exporting tags. if (courseId && courseName && location.hash === '#export-tags') { @@ -384,6 +402,28 @@ const CourseOutline = ({ courseId }: CourseOutlineProps) => {
{sections.length ? ( <> + {isAiEnabled && courseActions.childAddable && ( + + +
+
+ {intl.formatMessage(aiMessages.sectionBannerTitle)} +
+
+ {intl.formatMessage(aiMessages.sectionBannerSubtitle)} +
+
+ +
+
+ )} { onApplied={handleAiApplied} /> )} + ({ usageKey: s.usageKey || s.id, displayName: s.displayName }))} + onClose={() => setIsSectionEditorOpen(false)} + onApplied={handleSectionApplied} + />
Date: Fri, 26 Jun 2026 10:23:23 +0530 Subject: [PATCH 2/3] refactor: remove unused selectedSection variable in SectionEditorSidebar --- src/ai-course-creator/SectionEditorSidebar.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ai-course-creator/SectionEditorSidebar.tsx b/src/ai-course-creator/SectionEditorSidebar.tsx index eed48c86da..1ea4819321 100644 --- a/src/ai-course-creator/SectionEditorSidebar.tsx +++ b/src/ai-course-creator/SectionEditorSidebar.tsx @@ -71,7 +71,6 @@ const SectionEditorSidebar = ({ const chatMessages = selectedKey ? (chatBySection[selectedKey] || []) : []; const canApply = selectedKey ? Boolean(canApplyBySection[selectedKey]) : false; - const selectedSection = sections.find((s) => s.usageKey === selectedKey); const appendMessage = useCallback((key: string, message: ChatMessageData) => { setChatBySection((prev) => ({ ...prev, [key]: [...(prev[key] || []), message] })); From 56c36588a95ccf5044f9f224aab6fa8d4c208952 Mon Sep 17 00:00:00 2001 From: tenkalden Date: Fri, 26 Jun 2026 10:24:16 +0530 Subject: [PATCH 3/3] refactor: fix indentation in SectionEditorSidebar chat thread rendering --- .../SectionEditorSidebar.tsx | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/src/ai-course-creator/SectionEditorSidebar.tsx b/src/ai-course-creator/SectionEditorSidebar.tsx index 1ea4819321..a48d25b090 100644 --- a/src/ai-course-creator/SectionEditorSidebar.tsx +++ b/src/ai-course-creator/SectionEditorSidebar.tsx @@ -354,33 +354,33 @@ const SectionEditorSidebar = ({ > {/* Spacer pushes messages to the bottom when thread is short */}
- {isLoadingThread && chatMessages.length === 0 && ( -
-
- )} - {chatMessages.map((message, idx) => ( - handleEditMessage(idx, newContent)} - /> - ))} - {isStreaming && ( - streamingText - ? - : ( -
-
- ) - )} -
+ {isLoadingThread && chatMessages.length === 0 && ( +
+
+ )} + {chatMessages.map((message, idx) => ( + handleEditMessage(idx, newContent)} + /> + ))} + {isStreaming && ( + streamingText + ? + : ( +
+
+ ) + )} +