diff --git a/localtypings/pxtarget.d.ts b/localtypings/pxtarget.d.ts index fec248589049..362e0a6680b6 100644 --- a/localtypings/pxtarget.d.ts +++ b/localtypings/pxtarget.d.ts @@ -1354,6 +1354,7 @@ declare namespace pxt.tour { steps: BubbleStep[]; showConfetti?: boolean; numberFinalStep?: boolean; // The last step will only be included in the step count if this is true. + footer?: string | JSX.Element; } const enum BubbleLocation { Above, diff --git a/react-common/components/controls/AIFooter.tsx b/react-common/components/controls/AIFooter.tsx new file mode 100644 index 000000000000..ac6258544fe0 --- /dev/null +++ b/react-common/components/controls/AIFooter.tsx @@ -0,0 +1,24 @@ +import { classList } from "../util"; +import { ThumbsFeedback } from "./Feedback/ThumbsFeedback"; + +interface AIFooterProps { + className?: string; // Optional class name to add to the footer + onFeedbackSelected: (positive: boolean | undefined) => void; // Callback function to handle feedback selection +} + +/** + * A component containing a standard AI disclaimer and feedback buttons. + */ +export const AIFooter = (props: AIFooterProps) => { + const { + className, + onFeedbackSelected + } = props; + + return ( +
+
{lf("AI generated content may be incorrect.")}
+ +
+ ); +}; diff --git a/react-common/components/controls/Feedback/ThumbsFeedback.tsx b/react-common/components/controls/Feedback/ThumbsFeedback.tsx new file mode 100644 index 000000000000..a014584d58f5 --- /dev/null +++ b/react-common/components/controls/Feedback/ThumbsFeedback.tsx @@ -0,0 +1,68 @@ +import * as React from "react"; +import { classList } from "../../util"; +import { Button } from "../Button"; + +interface ThumbsFeedbackProps { + onFeedbackSelected: (positive: boolean | undefined) => void; // Callback function to handle feedback selection + lockOnSelect?: boolean; // If true, the user cannot change their selection once made + positiveFeedbackText?: string; // Tooltip text for the thumbs up button (not displayed) + negativeFeedbackText?: string; // Tooltip text for the thumbs down button (not displayed) + rootClassName?: string; // Optional class name to add to the root element + positiveClassName?: string; // Optional class name to add to the thumbs up button + negativeClassName?: string; // Optional class name to add to the thumbs down button +} + +/** + * A component for gathering simple thumbs up/down feedback. + */ +export const ThumbsFeedback = (props: ThumbsFeedbackProps) => { + const { + lockOnSelect, + onFeedbackSelected, + positiveFeedbackText, + negativeFeedbackText, + rootClassName, + positiveClassName, + negativeClassName, + } = props; + const [selectedFeedback, setSelectedFeedback] = React.useState(undefined); + + const handleFeedbackSelected = (positive: boolean) => { + if (positive === selectedFeedback) { + // If the user clicks the same feedback button again, reset it + setSelectedFeedback(undefined); + onFeedbackSelected(undefined); + } else { + setSelectedFeedback(positive); + onFeedbackSelected(positive); + } + }; + + const positiveText = positiveFeedbackText || lf("Helpful"); + const negativeText = negativeFeedbackText || lf("Not Helpful"); + const lockButtons = lockOnSelect && selectedFeedback !== undefined; + return ( +
+
+ ); +}; diff --git a/react-common/components/controls/TeachingBubble.tsx b/react-common/components/controls/TeachingBubble.tsx index a088c757fba2..f40335146cd3 100644 --- a/react-common/components/controls/TeachingBubble.tsx +++ b/react-common/components/controls/TeachingBubble.tsx @@ -30,6 +30,7 @@ export interface TeachingBubbleProps extends ContainerProps { onNext: () => void; onBack: () => void; onFinish: () => void; + footer?: string | JSX.Element; } export const TeachingBubble = (props: TeachingBubbleProps) => { @@ -45,6 +46,7 @@ export const TeachingBubble = (props: TeachingBubbleProps) => { onNext, onBack, onFinish, + footer, stepNumber, totalSteps, parentElement, @@ -379,14 +381,14 @@ export const TeachingBubble = (props: TeachingBubbleProps) => { ariaLabel={closeLabel} rightIcon="fas fa-times-circle" /> -
+
{targetContent.title}

{targetContent.description}

-
+
{hasSteps &&
{stepNumber} of {totalSteps}
} -
+
{hasPrevious &&
+ {footer &&
+ {footer} +
}
, parentElement || document.getElementById("root") || document.body) } diff --git a/react-common/styles/controls/AIFooter.less b/react-common/styles/controls/AIFooter.less new file mode 100644 index 000000000000..1c7e4c635216 --- /dev/null +++ b/react-common/styles/controls/AIFooter.less @@ -0,0 +1,14 @@ +.ai-footer { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + width: 100%; + height: fit-content; + font-size: 14px; + line-height: 14px; + + .feedback-button i { + font-size: 14px; + } +} \ No newline at end of file diff --git a/react-common/styles/controls/ThumbsFeedback.less b/react-common/styles/controls/ThumbsFeedback.less new file mode 100644 index 000000000000..f348d553c364 --- /dev/null +++ b/react-common/styles/controls/ThumbsFeedback.less @@ -0,0 +1,20 @@ +.feedback-buttons { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + + .common-button.feedback-button { + background: none; + padding: 0.1rem 0; + margin: 0 0.2rem 0 0; + + i { + margin: 0; + } + + &:hover:not(.disabled) { + filter: opacity(0.7); + } + } +} \ No newline at end of file diff --git a/react-common/styles/onboarding/TeachingBubble.less b/react-common/styles/onboarding/TeachingBubble.less index a0e1ff174a3d..3bd049fb7893 100644 --- a/react-common/styles/onboarding/TeachingBubble.less +++ b/react-common/styles/onboarding/TeachingBubble.less @@ -44,7 +44,6 @@ color: var(--teaching-bubble-foreground); box-shadow: 0 0 0 0.1rem; border-radius: 0.5rem; - padding: 1rem; z-index: @modalDimmerZIndex; .common-button { @@ -74,20 +73,25 @@ color: var(--teaching-bubble-foreground); } -.teaching-bubble-content { +.teaching-bubble-body { + padding: 1rem; font-size: 1.1rem; p { - margin: .25rem 0 .5rem; + margin: 0.5rem 0; } } -.teaching-bubble-footer { +.teaching-bubble-navigation { display: flex; flex-direction: row; align-items: flex-end; justify-content: space-between; + .common-button.feedback-button { + color: var(--teaching-bubble-foreground); + } + .teaching-bubble-steps { font-size: .9rem; color: var(--teaching-bubble-foreground); @@ -121,6 +125,19 @@ } } +.teaching-bubble-footer { + color: var(--pxt-neutral-alpha80); + margin-top: 0.5rem; + margin: 0; // negative margins compensate for the padding on the bubble + padding: 0.5rem 1rem; + border-top: 1px solid var(--pxt-neutral-alpha50); + + .ai-footer .feedback-button.disabled { + // Override the default disabled coloring + color: var(--pxt-neutral-alpha80); + } +} + .teaching-bubble-close.common-button { position: absolute; right: 0.5rem; @@ -148,7 +165,7 @@ color: @highContrastTextColor; border: solid @highContrastTextColor; - .teaching-bubble-navigation>.common-button { + .teaching-bubble-navigation-buttons>.common-button { color: @highContrastTextColor; border: solid @highContrastTextColor; } diff --git a/react-common/styles/react-common.less b/react-common/styles/react-common.less index 2b0fa532f9d8..6be6fc59f4cc 100644 --- a/react-common/styles/react-common.less +++ b/react-common/styles/react-common.less @@ -28,6 +28,8 @@ @import "controls/Accordion.less"; @import "controls/CarouselNav.less"; @import "controls/Feedback.less"; +@import "controls/ThumbsFeedback.less"; +@import "controls/AIFooter.less"; @import "theming/base-theme.less"; @import "./react-common-variables.less"; @import "./semantic-ui-overrides.less"; diff --git a/theme/ai-error-explanation-text.less b/theme/ai-error-explanation-text.less new file mode 100644 index 000000000000..4ec67b3b0180 --- /dev/null +++ b/theme/ai-error-explanation-text.less @@ -0,0 +1,18 @@ +.ai-explanation-container { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + width: 100%; + height: 100%; + white-space: pre-line; // Preserve new lines + + .ai-footer { + margin-top: 1rem; + + .feedback-button.disabled { + // Override the default disabled coloring + color: var(--pxt-neutral1-foreground); + } + } +} \ No newline at end of file diff --git a/theme/pxt.less b/theme/pxt.less index 415de4c0233a..5a6664bf5fbf 100644 --- a/theme/pxt.less +++ b/theme/pxt.less @@ -27,6 +27,7 @@ @import 'errorList'; @import 'asset-editor'; @import 'semantic-ui-overrides'; +@import 'ai-error-explanation-text'; @import 'light'; @import 'accessibility'; diff --git a/webapp/src/blocks.tsx b/webapp/src/blocks.tsx index 646af4d6db5d..24746023ad8f 100644 --- a/webapp/src/blocks.tsx +++ b/webapp/src/blocks.tsx @@ -41,6 +41,7 @@ import { Measurements } from "./constants"; import { flow } from "../../pxtblocks"; import { HIDDEN_CLASS_NAME } from "../../pxtblocks/plugins/flyout/blockInflater"; import { FlyoutButton } from "../../pxtblocks/plugins/flyout/flyoutButton"; +import { AIFooter } from "../../react-common/components/controls/AIFooter"; interface CopyDataEntry { version: 1; @@ -1035,6 +1036,7 @@ export class Editor extends toolboxeditor.ToolboxEditor { const validBlockIds = this.parent.getBlocks().map((b) => b.id); const tourSteps: pxt.tour.BubbleStep[] = []; + let invalidBlockIdCount = 0; for (const step of response.explanationSteps) { const tourStep = { title: lf("Error Explanation"), @@ -1050,6 +1052,7 @@ export class Editor extends toolboxeditor.ToolboxEditor { } else { // Do not add the tour target, but keep the step in case it's still helpful. pxt.tickEvent("errorHelp.invalidBlockId"); + invalidBlockIdCount++; } tourSteps.push(tourStep); @@ -1057,10 +1060,20 @@ export class Editor extends toolboxeditor.ToolboxEditor { return { steps: tourSteps, showConfetti: false, - numberFinalStep: true + numberFinalStep: true, + footer: this.handleErrorHelpFeedback(positive, { + type: "tour", + tourStepCount: response.explanationSteps.length, + errorCount: this.errors.length, + invalidBlockIdCount: invalidBlockIdCount, + })} /> }; } + private handleErrorHelpFeedback(positive: boolean, responseData: any) { + pxt.tickEvent("errorHelp.feedback", { ...responseData, positive: positive + "" }); + } + getBlocksAreaDiv() { return document.getElementById('blocksArea'); } diff --git a/webapp/src/components/AIErrorExplanationText.tsx b/webapp/src/components/AIErrorExplanationText.tsx new file mode 100644 index 000000000000..2a5c60a75476 --- /dev/null +++ b/webapp/src/components/AIErrorExplanationText.tsx @@ -0,0 +1,24 @@ +import { AIFooter } from "../../../react-common/components/controls/AIFooter"; + +interface AIErrorExplanationTextProps { + explanation: string; + onFeedbackSelected: (positive: boolean) => void; +} + +/** + * A simple component to encapsulate how we display paragraph-form AI generated error explanations. + * Mostly exists to encapsulate the disclaimer and feedback footer. + */ +export const AIErrorExplanationText = (props: AIErrorExplanationTextProps) => { + const { + explanation, + onFeedbackSelected, + } = props; + + return ( +
+
{explanation}
+ +
+ ); +}; diff --git a/webapp/src/components/onboarding/Tour.tsx b/webapp/src/components/onboarding/Tour.tsx index 469b23864a59..014053936a1c 100644 --- a/webapp/src/components/onboarding/Tour.tsx +++ b/webapp/src/components/onboarding/Tour.tsx @@ -8,7 +8,7 @@ export interface TourProps { export const Tour = (props: TourProps) => { const { onClose, config } = props; - const { steps } = config; + const { steps, footer } = config; const [currentStep, setCurrentStep] = useState(0); const tourStartTime = useRef(Date.now()); const stepStartTime = useRef(Date.now()); @@ -69,5 +69,6 @@ export const Tour = (props: TourProps) => { onFinish={onFinish} showConfetti={confetti} forceHideSteps={hideSteps} + footer={footer} /> }; \ No newline at end of file diff --git a/webapp/src/errorList.tsx b/webapp/src/errorList.tsx index 2bc0830ce8fc..a189e4e924bc 100644 --- a/webapp/src/errorList.tsx +++ b/webapp/src/errorList.tsx @@ -36,7 +36,7 @@ export type ErrorDisplayInfo = { export interface ErrorListProps { onSizeChange?: (state: pxt.editor.ErrorListState) => void; errors: ErrorDisplayInfo[]; - note?: string; + note?: string | JSX.Element; startDebugger?: () => void; getErrorHelp?: () => Promise; // Should return a promise that resolves when the help is loaded showLoginDialog?: ( diff --git a/webapp/src/monaco.tsx b/webapp/src/monaco.tsx index c17bc7b6b740..cb386d7273e6 100644 --- a/webapp/src/monaco.tsx +++ b/webapp/src/monaco.tsx @@ -32,6 +32,7 @@ import ErrorListState = pxt.editor.ErrorListState; import * as pxtblockly from "../../pxtblocks"; import { ThemeManager } from "../../react-common/components/theming/themeManager"; import { ErrorHelpException, getErrorHelpAsText } from "./errorHelp"; +import { AIErrorExplanationText } from "./components/AIErrorExplanationText"; const MIN_EDITOR_FONT_SIZE = 10 const MAX_EDITOR_FONT_SIZE = 40 @@ -656,18 +657,37 @@ export class Editor extends toolboxeditor.ToolboxEditor { setInsertionSnippet={this.setInsertionSnippet} parent={this.parent} />
- {showErrorList && } + {showErrorList && ( + + ) + } + showLoginDialog={this.parent.showLoginDialog} + /> + )}
) } + private onAIFeedback = (positive: boolean) => { + pxt.tickEvent("errorHelp.feedback", { + positive: positive + "", + type: "text", + responseLength: this.parent.state.errorListNote?.length + "", + errorCount: this.errors.length + }); + } + public onExceptionDetected(exception: pxsim.DebuggerBreakpointMessage) { const exceptionDisplayInfo: ErrorDisplayInfo = this.getDisplayInfoForException(exception); this.errors = [exceptionDisplayInfo];