From 6d14463d9de246a31f2d919854be1eeabd252a2e Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 24 Oct 2025 06:57:19 +0000 Subject: [PATCH] feat: add kangaroo Easter egg to chat interface - Created KangarooEasterEgg component with 5 different jokes - Triggers after typing "kangaroo" three times within 10 seconds - Displays random joke with animated punchline reveal - Added comprehensive test coverage - Subtle, dismissible overlay that does not interrupt workflow --- .../src/components/chat/ChatTextArea.tsx | 684 +++++++++--------- .../src/components/chat/KangarooEasterEgg.tsx | 89 +++ .../chat/__tests__/KangarooEasterEgg.spec.tsx | 102 +++ 3 files changed, 551 insertions(+), 324 deletions(-) create mode 100644 webview-ui/src/components/chat/KangarooEasterEgg.tsx create mode 100644 webview-ui/src/components/chat/__tests__/KangarooEasterEgg.spec.tsx diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index c7813372fa79..58b8cebc820d 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -32,6 +32,7 @@ import ContextMenu from "./ContextMenu" import { IndexingStatusBadge } from "./IndexingStatusBadge" import { usePromptHistory } from "./hooks/usePromptHistory" import { CloudAccountSwitcher } from "../cloud/CloudAccountSwitcher" +import { KangarooEasterEgg } from "./KangarooEasterEgg" interface ChatTextAreaProps { inputValue: string @@ -213,6 +214,9 @@ export const ChatTextArea = forwardRef( const contextMenuContainerRef = useRef(null) const [isEnhancingPrompt, setIsEnhancingPrompt] = useState(false) const [isFocused, setIsFocused] = useState(false) + const [showKangarooEasterEgg, setShowKangarooEasterEgg] = useState(false) + const kangarooCountRef = useRef(0) + const kangarooTimeoutRef = useRef(null) // Use custom hook for prompt history navigation const { handleHistoryNavigation, resetHistoryNavigation, resetOnInputChange } = usePromptHistory({ @@ -560,6 +564,27 @@ export const ChatTextArea = forwardRef( const newCursorPosition = e.target.selectionStart setCursorPosition(newCursorPosition) + // Easter egg detection: trigger on typing "kangaroo" three times + if (newValue.toLowerCase().includes("kangaroo")) { + kangarooCountRef.current++ + + // Clear existing timeout + if (kangarooTimeoutRef.current) { + clearTimeout(kangarooTimeoutRef.current) + } + + // Reset counter after 10 seconds + kangarooTimeoutRef.current = setTimeout(() => { + kangarooCountRef.current = 0 + }, 10000) + + // Show Easter egg on third "kangaroo" + if (kangarooCountRef.current >= 3) { + setShowKangarooEasterEgg(true) + kangarooCountRef.current = 0 + } + } + const showMenu = shouldShowContextMenu(newValue, newCursorPosition) setShowContextMenu(showMenu) @@ -908,362 +933,373 @@ export const ChatTextArea = forwardRef( }, []) return ( -
-
-
{ - // Only allowed to drop images/files on shift key pressed. - if (!e.shiftKey) { - setIsDraggingOver(false) - return - } - - e.preventDefault() - setIsDraggingOver(true) - e.dataTransfer.dropEffect = "copy" - }} - onDragLeave={(e) => { - e.preventDefault() - const rect = e.currentTarget.getBoundingClientRect() - - if ( - e.clientX <= rect.left || - e.clientX >= rect.right || - e.clientY <= rect.top || - e.clientY >= rect.bottom - ) { - setIsDraggingOver(false) - } - }}> - {showContextMenu && ( -
- -
- )} - + <> + {showKangarooEasterEgg && setShowKangarooEasterEgg(false)} />} +
+
+ "flex-col", + "outline-none", + )} + onDrop={handleDrop} + onDragOver={(e) => { + // Only allowed to drop images/files on shift key pressed. + if (!e.shiftKey) { + setIsDraggingOver(false) + return + } + + e.preventDefault() + setIsDraggingOver(true) + e.dataTransfer.dropEffect = "copy" + }} + onDragLeave={(e) => { + e.preventDefault() + const rect = e.currentTarget.getBoundingClientRect() + + if ( + e.clientX <= rect.left || + e.clientX >= rect.right || + e.clientY <= rect.top || + e.clientY >= rect.bottom + ) { + setIsDraggingOver(false) + } + }}> + {showContextMenu && ( +
+ +
+ )} +
- { - if (typeof ref === "function") { - ref(el) - } else if (ref) { - ref.current = el - } - textAreaRef.current = el - }} - value={inputValue} - onChange={(e) => { - handleInputChange(e) - updateHighlights() - }} - onFocus={() => setIsFocused(true)} - onKeyDown={(e) => { - // Handle ESC to cancel in edit mode - if (isEditMode && e.key === "Escape" && !e.nativeEvent?.isComposing) { - e.preventDefault() - onCancel?.() - return - } - handleKeyDown(e) - }} - onKeyUp={handleKeyUp} - onBlur={handleBlur} - onPaste={handlePaste} - onSelect={updateCursorPosition} - onMouseUp={updateCursorPosition} - onHeightChange={(height) => { - if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) { - setTextAreaBaseHeight(height) - } - - onHeightChange?.(height) - }} - placeholder={placeholderText} - minRows={3} - maxRows={15} - autoFocus={true} - className={cn( - "w-full", - "text-vscode-input-foreground", - "font-vscode-font-family", - "text-vscode-editor-font-size", - "leading-vscode-editor-line-height", - "cursor-text", - "py-2 pl-2", - isFocused - ? "border border-vscode-focusBorder outline outline-vscode-focusBorder" - : isDraggingOver - ? "border-2 border-dashed border-vscode-focusBorder" - : "border border-transparent", - isDraggingOver - ? "bg-[color-mix(in_srgb,var(--vscode-input-background)_95%,var(--vscode-focusBorder))]" - : "bg-vscode-input-background", - "transition-background-color duration-150 ease-in-out", - "will-change-background-color", - "min-h-[94px]", - "box-border", "rounded", - "resize-none", - "overflow-x-hidden", - "overflow-y-auto", - isEditMode ? "pr-20" : "pr-9", - "flex-none flex-grow", - "z-[2]", - "scrollbar-none", - "scrollbar-hide", - )} - onScroll={() => updateHighlights()} - /> + )}> +
+ { + if (typeof ref === "function") { + ref(el) + } else if (ref) { + ref.current = el + } + textAreaRef.current = el + }} + value={inputValue} + onChange={(e) => { + handleInputChange(e) + updateHighlights() + }} + onFocus={() => setIsFocused(true)} + onKeyDown={(e) => { + // Handle ESC to cancel in edit mode + if (isEditMode && e.key === "Escape" && !e.nativeEvent?.isComposing) { + e.preventDefault() + onCancel?.() + return + } + handleKeyDown(e) + }} + onKeyUp={handleKeyUp} + onBlur={handleBlur} + onPaste={handlePaste} + onSelect={updateCursorPosition} + onMouseUp={updateCursorPosition} + onHeightChange={(height) => { + if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) { + setTextAreaBaseHeight(height) + } + + onHeightChange?.(height) + }} + placeholder={placeholderText} + minRows={3} + maxRows={15} + autoFocus={true} + className={cn( + "w-full", + "text-vscode-input-foreground", + "font-vscode-font-family", + "text-vscode-editor-font-size", + "leading-vscode-editor-line-height", + "cursor-text", + "py-2 pl-2", + isFocused + ? "border border-vscode-focusBorder outline outline-vscode-focusBorder" + : isDraggingOver + ? "border-2 border-dashed border-vscode-focusBorder" + : "border border-transparent", + isDraggingOver + ? "bg-[color-mix(in_srgb,var(--vscode-input-background)_95%,var(--vscode-focusBorder))]" + : "bg-vscode-input-background", + "transition-background-color duration-150 ease-in-out", + "will-change-background-color", + "min-h-[94px]", + "box-border", + "rounded", + "resize-none", + "overflow-x-hidden", + "overflow-y-auto", + isEditMode ? "pr-20" : "pr-9", + "flex-none flex-grow", + "z-[2]", + "scrollbar-none", + "scrollbar-hide", + )} + onScroll={() => updateHighlights()} + /> -
- - - - - - - {isEditMode && ( - +
+ + + + + {isEditMode && ( + + + + )} + + + +
+ + {!inputValue && ( +
+ {placeholderBottomText} +
)} - +
+
+
+ + {selectedImages.length > 0 && ( + + )} + +
+
+ + + +
+
+ {isTtsPlaying && ( + -
- - {!inputValue && ( -
- {placeholderBottomText} -
)} + {!isEditMode ? : null} + {!isEditMode && cloudUserInfo && }
- - {selectedImages.length > 0 && ( - - )} - -
-
- - - -
-
- {isTtsPlaying && ( - - - - )} - {!isEditMode ? : null} - {!isEditMode && cloudUserInfo && } -
-
-
+ ) }, ) diff --git a/webview-ui/src/components/chat/KangarooEasterEgg.tsx b/webview-ui/src/components/chat/KangarooEasterEgg.tsx new file mode 100644 index 000000000000..d8f8275802f3 --- /dev/null +++ b/webview-ui/src/components/chat/KangarooEasterEgg.tsx @@ -0,0 +1,89 @@ +import React, { useEffect, useState } from "react" +import { X } from "lucide-react" +import { cn } from "@src/lib/utils" + +interface KangarooEasterEggProps { + onClose: () => void +} + +const kangarooJokes = [ + { + setup: "Why don't kangaroos make good dancers?", + punchline: "Because they have two left feet!", + }, + { + setup: "What do you call a lazy kangaroo?", + punchline: "A pouch potato!", + }, + { + setup: "What's a kangaroo's favorite year?", + punchline: "Leap year!", + }, + { + setup: "Why did the kangaroo stop drinking coffee?", + punchline: "It made him too jumpy!", + }, + { + setup: "What do you call a kangaroo at the North Pole?", + punchline: "Lost!", + }, +] + +export const KangarooEasterEgg: React.FC = ({ onClose }) => { + const [joke] = useState(() => kangarooJokes[Math.floor(Math.random() * kangarooJokes.length)]) + const [showPunchline, setShowPunchline] = useState(false) + + useEffect(() => { + const timer = setTimeout(() => setShowPunchline(true), 1500) + return () => clearTimeout(timer) + }, []) + + return ( +
+
e.stopPropagation()}> + + +
+
🦘
+
+

{joke.setup}

+ {showPunchline && ( +

+ {joke.punchline} +

+ )} +
+

+ You found the Roo Code Easter egg! +

+
+
+
+ ) +} diff --git a/webview-ui/src/components/chat/__tests__/KangarooEasterEgg.spec.tsx b/webview-ui/src/components/chat/__tests__/KangarooEasterEgg.spec.tsx new file mode 100644 index 000000000000..4af3631b60e1 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/KangarooEasterEgg.spec.tsx @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { KangarooEasterEgg } from "../KangarooEasterEgg" + +describe("KangarooEasterEgg", () => { + const mockOnClose = vi.fn() + + beforeEach(() => { + mockOnClose.mockClear() + }) + + it("renders the kangaroo emoji", () => { + render() + expect(screen.getByText("🦘")).toBeInTheDocument() + }) + + it("displays a joke setup immediately", () => { + render() + // Check that some joke setup text is present (we don't know which random one) + const jokeSetups = [ + "Why don't kangaroos make good dancers?", + "What do you call a lazy kangaroo?", + "What's a kangaroo's favorite year?", + "Why did the kangaroo stop drinking coffee?", + "What do you call a kangaroo at the North Pole?", + ] + const hasSetup = jokeSetups.some((setup) => { + try { + screen.getByText(setup) + return true + } catch { + return false + } + }) + expect(hasSetup).toBe(true) + }) + + it("displays the punchline after a delay", async () => { + render() + + // Punchline should appear after 1.5 seconds + await waitFor( + () => { + const punchlines = [ + "Because they have two left feet!", + "A pouch potato!", + "Leap year!", + "It made him too jumpy!", + "Lost!", + ] + const hasPunchline = punchlines.some((punchline) => { + try { + screen.getByText(punchline) + return true + } catch { + return false + } + }) + expect(hasPunchline).toBe(true) + }, + { timeout: 2000 }, + ) + }) + + it("displays the Easter egg message", () => { + render() + expect(screen.getByText("You found the Roo Code Easter egg!")).toBeInTheDocument() + }) + + it("calls onClose when close button is clicked", async () => { + const user = userEvent.setup() + render() + + const closeButton = screen.getByRole("button", { name: /close/i }) + await user.click(closeButton) + + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it("calls onClose when clicking the backdrop", async () => { + const user = userEvent.setup() + const { container } = render() + + // Click on the backdrop (the fixed div with the blur) + const backdrop = container.firstChild as HTMLElement + await user.click(backdrop) + + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it("does not close when clicking inside the dialog", async () => { + const user = userEvent.setup() + render() + + // Click on the kangaroo emoji (inside the dialog) + const emoji = screen.getByText("🦘") + await user.click(emoji) + + expect(mockOnClose).not.toHaveBeenCalled() + }) +})