Skip to content

Commit 41d7f69

Browse files
authored
feat: suggestions (UI-1807)(UI-1809)(UI-1810)(UI-1811) (#1300)
## Description **Summary** **Feature:** “Suggestions” UX for AI assistant and code fixes. **Scope:** New AI textarea with auto‑grow, AI modal flow, diff navigation in code fix modal, and editor support for single/bulk code fix operations. **Context** - Improves the onboarding “Build workflows in plain English” flow with a better input experience and prompt presets. - Enhances AI-generated code fix review and application, including add/modify/delete operations and bulk apply. **Changes** - AI page: Reworked layout and flow; opens AI assistant in a modal; adds example prompts and validation. - Input UX: New AiTextArea with auto-grow, responsive min/max height, shift+enter hint, and submit button. - Code fixes: - Diff modal: Adds next/previous change toolbar and “add/delete/modify” modes. - Editor: Listens to code fix events; supports bulk suggestions; updates active model and cache on apply. - Iframe comms: More robust handshake/queueing; new events for code fix suggestions and downloads. - Popover: Fix for broken popover behavior. ## Linear Ticket ## What type of PR is this? (check all applicable) - [x] 💡 (feat) - A new feature (non-breaking change which adds functionality) - [ ] 🔄 (refactor) - Code Refactoring - A code change that neither fixes a bug nor adds a feature - [ ] 🐞 (fix) - Bug Fix (non-breaking change which fixes an issue) - [ ] 🏎 (perf) - Optimization - [ ] 📄 (docs) - Documentation - Documentation only changes - [ ] 📄 (test) - Tests - Adding missing tests or correcting existing tests - [ ] 🎨 (style) - Styles - Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) - [ ] ⚙️ (ci) - Continuous Integrations - Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs) - [ ] ☑️ (chore) - Chores - Other changes that don't modify src or test files - [ ] ↩️ (revert) - Reverts - Reverts a previous commit(s). <!-- For a timely review/response, please avoid force-pushing additional commits if your PR already received reviews or comments. Before submitting a Pull Request, please ensure you've done the following: - 👷‍♀️ Create small PRs. In most cases this will be possible. - ✅ Provide tests for your changes. - 📝 Use descriptive commit messages (as described below). - 📗 Update any related documentation and include any relevant screenshots. Commit Message Structure (all lower-case): <type>(optional ticket number): <description> [optional body] -->
1 parent 0426ef9 commit 41d7f69

36 files changed

+1465
-343
lines changed
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react";
2+
3+
import { useTranslation } from "react-i18next";
4+
5+
import { AiTextAreaProps } from "@interfaces/components/forms/aiTextarea.interface";
6+
import { cn } from "@utilities";
7+
8+
export const AiTextArea = forwardRef<HTMLTextAreaElement, AiTextAreaProps>(
9+
(
10+
{
11+
className,
12+
onBlur,
13+
onChange,
14+
onEnterSubmit = true,
15+
onFocus,
16+
onKeyDown,
17+
onShiftEnterNewLine = true,
18+
onSubmitIconHover,
19+
placeholder,
20+
useDefaultPlaceholder = true,
21+
submitIcon,
22+
hasClearedTextarea = false,
23+
onClearTextarea,
24+
defaultPlaceholderText,
25+
autoGrow = true,
26+
minHeightVh = 8,
27+
maxHeightVh,
28+
...rest
29+
},
30+
ref
31+
) => {
32+
const { t } = useTranslation("chatbot");
33+
const internalRef = useRef<HTMLTextAreaElement>(null);
34+
const textareaRef = internalRef;
35+
const [isFocused, setIsFocused] = useState(false);
36+
const [isBlurred, setIsBlurred] = useState(false);
37+
const [isEmpty, setIsEmpty] = useState(false);
38+
39+
const [windowHeight, setWindowHeight] = useState(window.innerHeight);
40+
41+
useEffect(() => {
42+
const handleResize = () => {
43+
setWindowHeight(window.innerHeight);
44+
};
45+
46+
window.addEventListener("resize", handleResize);
47+
return () => {
48+
window.removeEventListener("resize", handleResize);
49+
// Cleanup focus timeout on unmount
50+
if (focusTimeoutRef.current) {
51+
clearTimeout(focusTimeoutRef.current);
52+
}
53+
};
54+
}, []);
55+
56+
const actualMinHeight = useMemo(() => {
57+
let responsiveMinHeightVh = minHeightVh;
58+
if (responsiveMinHeightVh === undefined) {
59+
if (windowHeight >= 1600) {
60+
responsiveMinHeightVh = 12;
61+
} else if (windowHeight >= 1200) {
62+
responsiveMinHeightVh = 10;
63+
} else {
64+
responsiveMinHeightVh = 8;
65+
}
66+
}
67+
return (windowHeight * responsiveMinHeightVh) / 100;
68+
}, [minHeightVh, windowHeight]);
69+
70+
const getMaxHeight = useCallback(() => {
71+
const viewportHeight = window.innerHeight;
72+
73+
if (maxHeightVh !== undefined) {
74+
return (viewportHeight * maxHeightVh) / 100;
75+
}
76+
77+
let responsiveMaxHeightVh;
78+
if (viewportHeight >= 1400) {
79+
responsiveMaxHeightVh = 70;
80+
} else if (viewportHeight >= 1000) {
81+
responsiveMaxHeightVh = 40;
82+
} else if (viewportHeight >= 800) {
83+
responsiveMaxHeightVh = 30;
84+
} else {
85+
responsiveMaxHeightVh = 25;
86+
}
87+
return (viewportHeight * responsiveMaxHeightVh) / 100;
88+
}, [maxHeightVh]);
89+
const adjustHeight = useCallback(() => {
90+
if (!autoGrow || !textareaRef.current) {
91+
return;
92+
}
93+
94+
const textarea = textareaRef.current;
95+
96+
const currentMaxHeight = getMaxHeight();
97+
98+
textarea.style.height = "auto";
99+
100+
const scrollHeight = textarea.scrollHeight;
101+
const newHeight = Math.min(Math.max(scrollHeight, actualMinHeight), currentMaxHeight);
102+
103+
textarea.style.height = `${newHeight}px`;
104+
}, [autoGrow, actualMinHeight, getMaxHeight, textareaRef]);
105+
106+
useEffect(() => {
107+
adjustHeight();
108+
}, [adjustHeight]);
109+
110+
useEffect(() => {
111+
if (autoGrow && textareaRef.current) {
112+
textareaRef.current.style.height = `${actualMinHeight}px`;
113+
}
114+
}, [autoGrow, actualMinHeight, textareaRef]);
115+
116+
const handleKeyDown = useCallback(
117+
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
118+
if (onEnterSubmit && e.key === "Enter" && !e.shiftKey) {
119+
e.preventDefault();
120+
const form = e.currentTarget.form;
121+
if (form) {
122+
form.requestSubmit();
123+
}
124+
} else if (onShiftEnterNewLine && e.key === "Enter" && e.shiftKey) {
125+
return;
126+
}
127+
onKeyDown?.(e);
128+
},
129+
[onKeyDown, onEnterSubmit, onShiftEnterNewLine]
130+
);
131+
132+
const focusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
133+
134+
const handleFocus = useCallback(
135+
(e: React.FocusEvent<HTMLTextAreaElement>) => {
136+
setIsFocused(true);
137+
setIsBlurred(false);
138+
setIsEmpty(!e.target.value);
139+
if (
140+
!hasClearedTextarea &&
141+
e.target.value === (defaultPlaceholderText || t("aiTextarea.defaultPlaceholder"))
142+
) {
143+
e.target.value = "";
144+
onClearTextarea?.(true);
145+
}
146+
onFocus?.(e);
147+
148+
// Clear existing timeout to prevent memory leaks
149+
if (focusTimeoutRef.current) {
150+
clearTimeout(focusTimeoutRef.current);
151+
}
152+
153+
focusTimeoutRef.current = setTimeout(() => {
154+
setIsFocused(false);
155+
focusTimeoutRef.current = null;
156+
}, 6000);
157+
},
158+
// eslint-disable-next-line react-hooks/exhaustive-deps
159+
[onFocus, hasClearedTextarea, defaultPlaceholderText]
160+
);
161+
162+
const handleChange = useCallback(
163+
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
164+
setIsEmpty(!e.target.value);
165+
onChange?.(e);
166+
setTimeout(() => {
167+
adjustHeight();
168+
}, 0);
169+
},
170+
[onChange, adjustHeight]
171+
);
172+
173+
const handleBlur = useCallback(
174+
(e: React.FocusEvent<HTMLTextAreaElement>) => {
175+
setIsFocused(false);
176+
setIsBlurred(true);
177+
setIsEmpty(!e.target.value);
178+
onBlur?.(e);
179+
},
180+
[onBlur]
181+
);
182+
183+
const dynamicStyles = useMemo(() => {
184+
if (autoGrow) return {};
185+
return {
186+
maxHeight: `${maxHeightVh || 27}vh`,
187+
minHeight: `${minHeightVh || 8}vh`,
188+
overflowY: "auto" as const,
189+
};
190+
}, [autoGrow, maxHeightVh, minHeightVh]);
191+
192+
const textAreaClass = cn(
193+
"w-full resize-none",
194+
autoGrow ? "overflow-y-auto" : "overflow-hidden",
195+
"rounded-2xl border-2 p-5 pr-16",
196+
"bg-black/90 text-base transition-all duration-300 ease-in-out",
197+
autoGrow ? "whitespace-pre-wrap break-words" : "",
198+
"placeholder:text-gray-400",
199+
// State-based styling for security
200+
{
201+
"border-green-400 text-white shadow-[0_0_20px_rgba(126,211,33,0.2)]": isFocused,
202+
"border-green-400/30": !isFocused,
203+
"text-gray-400": !isFocused && !isEmpty,
204+
"text-gray-500": isBlurred && isEmpty,
205+
},
206+
className
207+
);
208+
209+
const submitButtonClass = cn(
210+
"absolute flex cursor-pointer items-center justify-center border-none",
211+
"transition-all duration-300 ease-in-out",
212+
"right-3 top-1/2 -translate-y-1/2",
213+
"size-9 rounded-lg bg-green-400 text-black",
214+
"hover:scale-105 hover:bg-green-500"
215+
);
216+
217+
return (
218+
<div className="relative mx-auto mb-6 max-w-700">
219+
{isFocused ? (
220+
<div className="absolute -top-7 left-0 z-10">
221+
<span className="rounded-md bg-black/60 px-2 py-1 text-xs text-green-200">
222+
{t("aiTextarea.shiftEnterHint")}
223+
</span>
224+
</div>
225+
) : null}
226+
<textarea
227+
{...rest}
228+
className={textAreaClass}
229+
onBlur={handleBlur}
230+
onChange={handleChange}
231+
onFocus={handleFocus}
232+
onKeyDown={handleKeyDown}
233+
placeholder={placeholder || (useDefaultPlaceholder ? t("aiTextarea.placeholder") : undefined)}
234+
ref={(element) => {
235+
(textareaRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = element;
236+
if (typeof ref === "function") {
237+
ref(element);
238+
} else if (ref) {
239+
(ref as React.MutableRefObject<HTMLTextAreaElement | null>).current = element;
240+
}
241+
}}
242+
style={dynamicStyles}
243+
/>
244+
{submitIcon ? (
245+
<button
246+
className={submitButtonClass}
247+
onMouseEnter={() => onSubmitIconHover?.(true)}
248+
onMouseLeave={() => onSubmitIconHover?.(false)}
249+
type="submit"
250+
>
251+
{submitIcon}
252+
</button>
253+
) : null}
254+
</div>
255+
);
256+
}
257+
);
258+
259+
AiTextArea.displayName = "AiTextArea";

src/components/atoms/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { AHref } from "@components/atoms/ahref";
2+
export { AiTextArea } from "@components/atoms/aiTextarea";
23
export { Badge } from "@components/atoms/badge";
34
export { Button, IconButton, ResizeButton } from "@components/atoms/buttons";
45
export { Checkbox } from "@components/atoms/checkbox";
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import React from "react";
2+
3+
import { useTranslation } from "react-i18next";
4+
5+
import { IconSvg } from "@components/atoms";
6+
7+
import { ArrowDown } from "@assets/image/icons";
8+
9+
interface DiffNavigationToolbarProps {
10+
canNavigateNext: boolean;
11+
canNavigatePrevious: boolean;
12+
onNext: () => void;
13+
onPrevious: () => void;
14+
className?: string;
15+
}
16+
17+
export const DiffNavigationToolbar: React.FC<DiffNavigationToolbarProps> = ({
18+
canNavigateNext,
19+
canNavigatePrevious,
20+
onNext,
21+
onPrevious,
22+
className = "",
23+
}) => {
24+
const { t } = useTranslation("chatbot");
25+
26+
return (
27+
<div className={`flex items-center gap-1 rounded-md bg-gray-800 p-1 ${className}`}>
28+
<button
29+
className={`flex size-6 items-center justify-center rounded text-xs transition-colors ${
30+
canNavigatePrevious
31+
? "text-gray-300 hover:bg-gray-700 hover:text-white"
32+
: "cursor-not-allowed text-gray-600"
33+
}`}
34+
disabled={!canNavigatePrevious}
35+
onClick={onPrevious}
36+
title={t("diffNavigation.previousChange")}
37+
type="button"
38+
>
39+
<IconSvg className="rotate-180" size="md" src={ArrowDown} />
40+
</button>
41+
<button
42+
className={`flex size-6 items-center justify-center rounded text-xs transition-colors ${
43+
canNavigateNext
44+
? "text-gray-300 hover:bg-gray-700 hover:text-white"
45+
: "cursor-not-allowed text-gray-600"
46+
}`}
47+
disabled={!canNavigateNext}
48+
onClick={onNext}
49+
title={t("diffNavigation.nextChange")}
50+
type="button"
51+
>
52+
<IconSvg size="md" src={ArrowDown} />
53+
</button>
54+
</div>
55+
);
56+
};

src/components/molecules/drawer.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export const Drawer = ({
3434
);
3535

3636
const wrapperClass = cn(
37-
"fixed right-0 top-0 z-[120] h-full",
37+
"fixed right-0 top-0 z-drawer h-full",
3838
{
3939
"w-550": !width,
4040
},
@@ -47,7 +47,7 @@ export const Drawer = ({
4747
const wrapperStyle = width ? { width: `${width}vw` } : {};
4848
const animationDistance = width && typeof window !== "undefined" ? window.innerWidth * (width / 100) : 500;
4949

50-
const bgClass = cn("fixed left-0 top-0 z-[110] flex size-full items-center justify-center backdrop-blur-sm", {
50+
const bgClass = cn("fixed left-0 top-0 z-overlay flex size-full items-center justify-center backdrop-blur-sm", {
5151
"backdrop-blur-none": bgTransparent,
5252
});
5353

src/components/molecules/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ export { JsonViewer } from "@components/molecules/jsonViewer";
2424
export { BillingSwitcher } from "@components/molecules/billingSwitcher";
2525
export { UsageProgressBar } from "@components/molecules/usageProgressBar";
2626
export { PlanComparisonTable } from "@components/molecules/planComparisonTable";
27+
export { DiffNavigationToolbar } from "@components/molecules/diffNavigationToolbar";

src/components/molecules/popover/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { PopoverWrapper } from "@components/molecules/popover/popover";
2-
export { PopoverContent, PopoverTrigger } from "@components/molecules/popover/popoverContent";
2+
export { PopoverContent } from "@components/molecules/popover/popoverContent";
3+
export { PopoverTrigger } from "@components/molecules/popover/popoverTrigger";
34
export { PopoverContentBase } from "@components/molecules/popover/popoverContentBase";
45
export { PopoverListWrapper } from "@components/molecules/popover/popoverList";
56
export { PopoverListContent } from "@components/molecules/popover/popoverListContent";

src/components/molecules/popover/popoverContent.tsx

Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,6 @@ import * as React from "react";
22

33
import { PopoverContentBase } from "./popoverContentBase";
44
import { usePopoverContext } from "@contexts/usePopover";
5-
import { PopoverTriggerProps } from "@src/interfaces/components";
6-
7-
import { useMergeRefsCustom } from "@components/molecules/popover/utilities";
8-
9-
export const PopoverTrigger = React.forwardRef<HTMLElement, React.HTMLProps<HTMLElement> & PopoverTriggerProps>(
10-
function PopoverTrigger({ children, ...props }, propRef) {
11-
const context = usePopoverContext();
12-
const childrenRef = React.isValidElement(children) ? (children as any).ref : null;
13-
const ref = useMergeRefsCustom(context.refs.setReference, propRef, childrenRef);
14-
15-
const onKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
16-
if (event.key === "Enter" || event.key === " ") {
17-
context.setOpen(true);
18-
}
19-
};
20-
21-
return (
22-
<button
23-
data-state={context.open ? "open" : "closed"}
24-
ref={ref}
25-
type="button"
26-
{...context.getReferenceProps(props)}
27-
onKeyDown={onKeyDown}
28-
>
29-
{children}
30-
</button>
31-
);
32-
}
33-
);
345

356
export const PopoverContent = React.forwardRef<HTMLDivElement, React.HTMLProps<HTMLDivElement>>(function PopoverContent(
367
{ style, ...props },

0 commit comments

Comments
 (0)