Skip to content

POC: add Ask AI tutor flow in bubble menu#3751

Draft
adomingc wants to merge 4 commits intomainfrom
feat/ask-ai-tutor-richtext
Draft

POC: add Ask AI tutor flow in bubble menu#3751
adomingc wants to merge 4 commits intomainfrom
feat/ask-ai-tutor-richtext

Conversation

@adomingc
Copy link
Copy Markdown
Contributor

Summary

  • add a new Ask AI Tutor button and dialog in the RichText bubble menu
  • wire NotesTextEditor and chat components to open guided tutoring from selected content
  • update stories and chat types/layout to support the new tutor entrypoint

Test plan

  • pre-commit hooks pass (format, lint, cycle dependencies)
  • validate Ask AI Tutor flow in Notes editor manually
  • verify storybook stories render and interaction works as expected

Made with Cursor

Add an Ask AI Tutor entrypoint in the RichText bubble menu.
Connect it with the chat window flow for guided tutoring.

Made-with: Cursor
Copilot AI review requested due to automatic review settings March 24, 2026 16:35
@github-actions github-actions bot added feat react Changes affect packages/react labels Mar 24, 2026
@adomingc adomingc changed the title feat(rich-text): add Ask AI tutor flow in bubble menu POC: add Ask AI tutor flow in bubble menu Mar 24, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 24, 2026

✅ No New Circular Dependencies

No new circular dependencies detected. Current count: 0

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 24, 2026

🔍 Visual review for your branch is published 🔍

Here are the links to:

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an “Ask AI Tutor” entrypoint from the RichText bubble menu that opens a small floating AI chat seeded with the user’s selected text, and wires the NotesTextEditor + stories to support this new flow.

Changes:

  • Extend F0AiChat visualization modes with a new "floating" mode and add a corresponding FloatingWindow.
  • Add RichText bubble menu “Ask AI Tutor” button + floating dialog (real CopilotKit-backed mode and a mock fallback).
  • Expose tutor config/types via NotesTextEditor and add a Storybook story demonstrating the tutor flow.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
packages/react/src/sds/ai/F0AiChat/types.ts Adds "floating" to the supported visualization modes.
packages/react/src/sds/ai/F0AiChat/components/layout/ChatWindow.tsx Introduces FloatingWindow window wrapper with motion animations.
packages/react/src/sds/ai/F0AiChat/F0AiChat.tsx Selects FloatingWindow vs SidebarWindow based on visualization mode.
packages/react/src/experimental/RichText/NotesTextEditor/index.tsx Adds aiTutorConfig prop and exports tutor-related types/helpers.
packages/react/src/experimental/RichText/NotesTextEditor/index.stories.tsx Adds WithAITutor story and sample readonly training content.
packages/react/src/experimental/RichText/CoreEditor/BubbleMenu/index.tsx Adds Ask AI Tutor button integration and floating chat positioning.
packages/react/src/experimental/RichText/CoreEditor/BubbleMenu/AskAITutorDialog.tsx New floating tutor chat (real + mock) and message injection helper.
packages/react/src/experimental/RichText/CoreEditor/BubbleMenu/AskAITutorButton.tsx New bubble menu button used to open the tutor chat.

Comment on lines +51 to +55
return messagesRef.current
.filter((m) => m.role === "user" || m.role === "assistant")
.map((m) => ({
role: m.role as "user" | "assistant",
content: "content" in m ? String(m.content) : "",
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

serializeMessages() coerces CopilotKit message content via String(m.content), which will produce values like "[object Object]" for non-string content (CopilotKit can send structured/array content). Extract plain text content the same way the chat message renderer does, so the onGoDeeper callback receives usable transcripts.

Suggested change
return messagesRef.current
.filter((m) => m.role === "user" || m.role === "assistant")
.map((m) => ({
role: m.role as "user" | "assistant",
content: "content" in m ? String(m.content) : "",
const extractPlainText = (content: unknown): string => {
if (typeof content === "string") {
return content
}
if (Array.isArray(content)) {
return content
.map((item) => extractPlainText(item))
.filter((part) => part)
.join(" ")
}
if (typeof content === "object" && content !== null) {
if (
"text" in content &&
typeof (content as { text: unknown }).text === "string"
) {
return (content as { text: string }).text
}
}
return ""
}
return messagesRef.current
.filter((m) => m.role === "user" || m.role === "assistant")
.map((m) => ({
role: m.role as "user" | "assistant",
content: "content" in m ? extractPlainText(m.content) : "",

Copilot uses AI. Check for mistakes.
Comment on lines +102 to +106
useEffect(() => {
if (!open && hasSentRef.current) {
onClose()
}
}, [open, onClose])
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The floating tutor container only calls onClose() when open becomes false and hasSentRef.current is true. If the user closes the chat before the initial message is sent (within the 500ms delay), the Copilot sidebar will close but the outer AskAITutorChat stays mounted (potentially leaving an empty 360x460 wrapper). Consider tracking "has been opened at least once" separately from "has sent" and close the outer container whenever the user closes the chat.

Copilot uses AI. Check for mistakes.
Comment on lines +193 to +200
setIsLoading(true)
setTimeout(() => {
setMessages((prev) => [
...prev,
{ role: "assistant", content: getMockResponse(next) },
])
setIsLoading(false)
}, 800)
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the mock tutor chat, handleSend schedules a setTimeout that is never cleared on unmount. Closing the tutor while a response is pending can trigger state updates after unmount. Store the timeout id in a ref and clear it in an effect cleanup or before scheduling a new timeout.

Copilot uses AI. Check for mistakes.
Comment on lines +81 to +85
(props.args.prompt as string) ??
"Would you like me to explain this in more detail?"
}
confirmationText="Go deeper"
cancelText="No, thanks"
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

User-facing strings for the tutor "go deeper" confirmation (prompt/CTA/cancel) are hardcoded here rather than coming from i18n/config. In this repo, UI copy is generally sourced from useI18n() or passed via props so host apps can translate it (see packages/react/AGENTS.md i18n guidelines). Consider adding these strings to AiTutorLabels (or a dedicated labels object) and/or using useI18n() for defaults.

Copilot uses AI. Check for mistakes.
aria-label={config.labels.buttonLabel}
title={config.labels.buttonTooltip}
className={cn(
"flex h-12 w-12 items-center justify-center rounded-full bg-white shadow-lg transition-transform hover:scale-110 active:scale-95",
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This button hardcodes bg-white, which will likely look incorrect in dark mode and doesn't follow the design-token convention used elsewhere in this repo (bg-f1-*, border-f1-*, etc.). Use token-based background/shadow styles so the tutor button theme matches the rest of the editor UI.

Suggested change
"flex h-12 w-12 items-center justify-center rounded-full bg-white shadow-lg transition-transform hover:scale-110 active:scale-95",
"flex h-12 w-12 items-center justify-center rounded-full bg-f1-surface shadow-f1-floating transition-transform hover:scale-110 active:scale-95",

Copilot uses AI. Check for mistakes.
Comment on lines +12 to +13
* Compact floating chat window — renders as a small card (360x460px).
* Used when visualizationMode is "floating".
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The FloatingWindow JSDoc says it "renders as a small card (360x460px)", but the component itself doesn't enforce sizing (it uses h-full w-full and relies on the parent). Either enforce the dimensions here or update the comment to avoid implying a constraint that isn't implemented.

Suggested change
* Compact floating chat window renders as a small card (360x460px).
* Used when visualizationMode is "floating".
* Compact floating chat window used when visualizationMode is "floating".
* The component fills the size provided by its parent container.

Copilot uses AI. Check for mistakes.
Comment on lines +394 to +398
<MockAiTutorChat
selectedText={selectedText}
greeting={chatConfig?.greeting}
onGoDeeper={onGoDeeper}
/>
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In mock mode, the floating tutor UI doesn't provide any way to close/dismiss the chat: MockAiTutorChat doesn't accept/use onClose, and the AskAITutorChat wrapper doesn't render a close control in this branch. If chatConfig.runtimeUrl is omitted (or in Storybook without a backend), users can get stuck with an always-open overlay. Consider adding a close button/escape handler for the mock branch and wiring it to onClose.

Copilot uses AI. Check for mistakes.
Comment on lines +72 to +77
setChatPosition({
top: end.bottom - editorRect.top + 8,
left: Math.min(
start.left - editorRect.left,
editorRect.width - 370 // ensure chat doesn't overflow right
),
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The computed floating chat left position is only clamped with Math.min(...) and can become negative when the selection starts near the left edge (or when editorRect.width < 370), causing the tutor chat to render off-screen. Clamp left to a valid range (e.g. between 0 and editorRect.width - chatWidth) and avoid the hardcoded 370px offset by deriving it from the actual chat width.

Suggested change
setChatPosition({
top: end.bottom - editorRect.top + 8,
left: Math.min(
start.left - editorRect.left,
editorRect.width - 370 // ensure chat doesn't overflow right
),
const chatWidth = 370
const rawLeft = start.left - editorRect.left
const maxLeft = Math.max(0, editorRect.width - chatWidth)
const clampedLeft = Math.max(0, Math.min(rawLeft, maxLeft))
setChatPosition({
top: end.bottom - editorRect.top + 8,
left: clampedLeft,

Copilot uses AI. Check for mistakes.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 24, 2026

📦 Alpha Package Version Published

Use pnpm i github:factorialco/f0#npm/alpha-pr-3751 to install the package

Use pnpm i github:factorialco/f0#3fdb2d676cd2c92b072fb85cd6403afb7b16d0e3 to install this specific commit

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 24, 2026

Coverage Report for packages/react

Status Category Percentage Covered / Total
🔵 Lines 43.9% 10401 / 23689
🔵 Statements 43.21% 10722 / 24808
🔵 Functions 35.89% 2347 / 6539
🔵 Branches 35.22% 6568 / 18647
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/react/src/experimental/RichText/CoreEditor/BubbleMenu/AskAITutorButton.tsx 8.33% 0% 0% 9.09% 31-47
packages/react/src/experimental/RichText/CoreEditor/BubbleMenu/AskAITutorDialog.tsx 4.09% 0% 0% 4.5% 41-135, 152-227, 241-378, 435-485, 499-527
packages/react/src/experimental/RichText/CoreEditor/BubbleMenu/index.tsx 25% 36.17% 20% 25% 66-83, 96-190
packages/react/src/experimental/RichText/NotesTextEditor/index.tsx 0% 0% 0% 0% 66-465
packages/react/src/sds/ai/F0AiChat/F0AiChat.tsx 31.57% 0% 0% 31.57% 41-63, 71-77, 82-115
packages/react/src/sds/ai/F0AiChat/types.ts 100% 100% 100% 100%
packages/react/src/sds/ai/F0AiChat/components/layout/ChatWindow.tsx 9.52% 0% 0% 10% 16-32, 45-87
Generated in workflow #12214 for commit c69ed1f by the Vitest Coverage Report Action

Copilot AI review requested due to automatic review settings March 26, 2026 09:56
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 8 comments.

Comment on lines +11 to +28
/**
* Compact floating chat window — renders as a small card (360x460px).
* Used when visualizationMode is "floating".
*/
export const FloatingWindow = ({ children }: WindowProps) => {
const { open } = useAiChat()

return (
<AnimatePresence>
{open && (
<motion.div
key="floating-chat"
className="pointer-events-auto flex h-full w-full flex-col overflow-hidden rounded-xl border border-solid border-f1-border-secondary bg-f1-special-page shadow-xl"
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ duration: 0.2, ease: "easeOut" }}
>
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FloatingWindow introduces motion animations but doesn’t respect the user’s “prefers-reduced-motion” setting. Per repo a11y conventions, please use useReducedMotion() (or equivalent) to disable/shorten transitions when reduced motion is requested.

Copilot uses AI. Check for mistakes.
Comment on lines +152 to +184
<div className="flex flex-col gap-2 rounded-xl border border-solid border-f1-border-secondary bg-f1-background p-3">
<p className="text-sm text-f1-foreground-secondary">{text}</p>
<div className="flex gap-2">
{onGoDeeper && (
<button
type="button"
onClick={onGoDeeper}
className="flex items-center gap-1.5 rounded-lg border border-solid border-f1-border bg-f1-background px-3 py-2 text-sm font-medium text-f1-foreground transition-colors hover:bg-f1-background-secondary"
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 3H3v10h10v-3M10 2h4v4M9 7l5-5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
Go deeper
</button>
)}
{onQuizMe && (
<button
type="button"
onClick={onQuizMe}
className="flex items-center gap-1.5 rounded-lg border border-solid border-f1-border bg-f1-background px-3 py-2 text-sm font-medium text-f1-foreground transition-colors hover:bg-f1-background-secondary"
>
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New focusable controls inside AiTutorNextStepActions don’t include focusRing() (and rely only on hover styles). This makes keyboard focus hard to see and conflicts with repo accessibility conventions. Add focusRing() (or equivalent visible focus styles) to these buttons.

Copilot uses AI. Check for mistakes.
Comment on lines +503 to +524
useEffect(() => {
if (!injectedRef.current && messages.length > 0) {
setMessages(
messages.map((m) => ({
id: randomId(),
role: m.role,
content: m.content,
}))
)
setOpen(true)
injectedRef.current = true

// After injecting history, send a follow-up message to trigger a deeper explanation
setTimeout(() => {
sendMessage({
id: randomId(),
role: "user",
content:
"Please go deeper into this topic. Provide a detailed explanation with examples, and include helpful resources like articles, videos, or documentation links.",
})
}, 500)
}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AiTutorMessageInjector starts a setTimeout but doesn’t clear it on unmount. If the provider unmounts quickly, this can attempt to send a message after teardown. Store the timeout id and clear it in the effect cleanup (and consider guarding against double-send).

Copilot uses AI. Check for mistakes.
Comment on lines +239 to +244
chatConfig: {
runtimeUrl: "http://localhost:4111/copilotkit",
agent: "one-workflow",
greeting:
"Hello! I'm your AI tutor. Highlight any text in the training material and I'll help you understand it better.",
},
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This story hardcodes a localhost runtimeUrl, which will fail in CI/Chromatic/hosted Storybook and can make the story unusable. Prefer omitting runtimeUrl to exercise the built-in mock mode, or mock the network/runtime in Storybook (MSW) so the story is deterministic.

Copilot uses AI. Check for mistakes.
Comment on lines +123 to +126
const timer = setTimeout(() => {
const prompt = `<tool-context tool="ai-tutor">Please give a brief, concise explanation (3-4 sentences max) of the following text in simpler terms:</tool-context>${selectedText}`
sendMessage(prompt)
hasSentRef.current = true
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The prompt is built by concatenating selectedText into a <tool-context> wrapper. Because selectedText can contain arbitrary HTML/text (including sequences like </tool-context>), this is vulnerable to prompt/tool-context injection. Escape or encode the selected text (e.g., wrap it in a JSON payload or otherwise sanitize/escape </>), and keep the tool-context section structurally unbreakable.

Copilot uses AI. Check for mistakes.
Comment on lines +473 to +485
// Mock mode: standalone chat UI
return (
<div
className="absolute z-[9999] h-[460px] w-[360px]"
style={positionStyle}
>
<MockAiTutorChat
selectedText={selectedText}
greeting={chatConfig?.greeting}
onGoDeeper={onGoDeeper}
onQuizMe={onQuizMe}
/>
</div>
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In mock mode there is no way to close the tutor widget (no close button and no onClose wiring), so once opened it can only be removed by re-rendering the editor. Add a close affordance (button + Escape handler) and invoke the provided onClose callback.

Copilot uses AI. Check for mistakes.
aria-label={config.labels.buttonLabel}
title={config.labels.buttonTooltip}
className={cn(
"flex h-12 w-12 items-center justify-center rounded-full bg-white shadow-lg transition-transform hover:scale-110 active:scale-95",
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new button uses a raw bg-white color. Styling guidelines prefer design tokens (bg-f1-*) for theming/dark mode consistency. Consider switching to the appropriate tokenized background color.

Suggested change
"flex h-12 w-12 items-center justify-center rounded-full bg-white shadow-lg transition-transform hover:scale-110 active:scale-95",
"flex h-12 w-12 items-center justify-center rounded-full bg-f1-surface-raised shadow-lg transition-transform hover:scale-110 active:scale-95",

Copilot uses AI. Check for mistakes.
type="button"
onClick={handleSend}
disabled={!input.trim() || isLoading}
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-f1-background-accent-bold text-white transition-opacity disabled:opacity-40"
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mock chat’s send button is focusable but lacks visible focus styles (focusRing() or similar). Please add an explicit keyboard focus indicator to meet the repo’s focus visibility convention.

Suggested change
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-f1-background-accent-bold text-white transition-opacity disabled:opacity-40"
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-f1-background-accent-bold text-white transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-f1-border-bold focus-visible:ring-offset-2 focus-visible:ring-offset-f1-background disabled:opacity-40"

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat react Changes affect packages/react

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants