Skip to content

Commit 26c490c

Browse files
authored
feat: ai chat modal maximized in the welcome screen (#1447)
1 parent 4c0a499 commit 26c490c

File tree

26 files changed

+984
-747
lines changed

26 files changed

+984
-747
lines changed

.commitlintrc.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"extends": ["@commitlint/config-conventional"],
33
"rules": {
4-
"type-enum": [2, "always", ["ci", "chore", "docs", "feat", "fix", "perf", "refactor", "revert", "test"]]
4+
"type-enum": [2, "always", ["ci", "chore", "docs", "feat", "fix", "perf", "refactor", "revert", "test"]],
5+
"body-max-line-length": [2, "always", 500]
56
}
67
}

.github/workflows/build_test_and_release.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ jobs:
183183
run: |
184184
git submodule update --remote
185185
npm run type-check
186+
npm run type-check
186187
npm run lint:ci
187188
npm run build
188189
echo '!dist' >> .gitignore

e2e/project/aiChat.spec.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { expect, test } from "../fixtures";
2+
import { initialPillsCount } from "@constants/pages/aiWelcomePage.constants";
3+
4+
test.describe("AI Chat and Iframe Communication Suite", () => {
5+
test.beforeEach(async ({ page }) => {
6+
await page.goto("/ai");
7+
await page.waitForLoadState("networkidle");
8+
});
9+
10+
test("AI landing page loads correctly with all elements", async ({ page }) => {
11+
await expect(page.getByRole("heading", { name: /Build AI Agents/i })).toBeVisible();
12+
await expect(page.getByRole("heading", { name: /Create workflows/i })).toBeVisible();
13+
14+
await expect(page.getByRole("button", { name: "Start from Template" })).toBeVisible();
15+
await expect(page.getByRole("button", { name: "New Project From Scratch" })).toBeVisible();
16+
17+
const textarea = page.locator('textarea[name="message"]');
18+
await expect(textarea).toBeVisible();
19+
});
20+
21+
test("Suggestion pills are visible and clickable", async ({ page }) => {
22+
const pills = page.locator("button[data-testid^='suggestion-pill-']");
23+
await expect(pills.first()).toBeVisible();
24+
25+
await pills.first().click();
26+
27+
const textarea = page.locator('textarea[name="message"]');
28+
await expect(textarea).toBeFocused();
29+
});
30+
31+
test("More button loads additional suggestion pills", async ({ page }) => {
32+
const initialPills = page.locator("button[data-testid^='suggestion-pill-']");
33+
const initialPillsCountActual = await initialPills.count();
34+
expect(initialPillsCountActual).toBe(initialPillsCount);
35+
36+
const initialPillsTitles: string[] = [];
37+
for (let i = 0; i < initialPillsCountActual; i++) {
38+
const pillText = await initialPills.nth(i).textContent();
39+
if (pillText) {
40+
initialPillsTitles.push(pillText.trim());
41+
}
42+
}
43+
44+
expect(initialPillsTitles).toHaveLength(initialPillsCount);
45+
expect(initialPillsTitles.every((title) => title && title.length > 0)).toBe(true);
46+
47+
const moreButton = page.getByRole("button", { name: "More", exact: true });
48+
if (await moreButton.isVisible()) {
49+
await moreButton.click();
50+
51+
await page.waitForTimeout(300);
52+
53+
const allVisiblePills = page.locator("button[data-testid^='suggestion-pill-']");
54+
const allPillsCount = await allVisiblePills.count();
55+
expect(allPillsCount).toBeGreaterThan(initialPillsCount);
56+
57+
const allPillsTitles: string[] = [];
58+
for (let i = 0; i < allPillsCount; i++) {
59+
const pillText = await allVisiblePills.nth(i).textContent();
60+
if (pillText) {
61+
allPillsTitles.push(pillText.trim());
62+
}
63+
}
64+
65+
expect(allPillsTitles).toHaveLength(allPillsCount);
66+
expect(allPillsTitles.every((title) => title && title.length > 0)).toBe(true);
67+
68+
const initialPillsFromExpanded = allPillsTitles.slice(0, initialPillsCount);
69+
expect(initialPillsFromExpanded).toEqual(initialPillsTitles);
70+
71+
await expect(moreButton).not.toBeVisible();
72+
}
73+
});
74+
75+
test("Submit message opens AI chat modal", async ({ page }) => {
76+
const textarea = page.locator('textarea[name="message"]');
77+
await textarea.fill("Create a GitHub webhook automation");
78+
79+
await page.keyboard.press("Enter");
80+
81+
const modal = page.locator('[class*="modal"], [role="dialog"], [data-testid="ai-chat-modal"]');
82+
await expect(modal.first()).toBeVisible({ timeout: 10000 });
83+
});
84+
85+
test("Empty message shows validation error", async ({ page }) => {
86+
const textarea = page.locator('textarea[name="message"]');
87+
await textarea.focus();
88+
await page.keyboard.press("Enter");
89+
90+
const errorMessage = page.getByText("Please enter a message");
91+
await expect(errorMessage).toBeVisible({ timeout: 5000 });
92+
});
93+
94+
test("Textarea clears validation error when typing", async ({ page }) => {
95+
const textarea = page.locator('textarea[name="message"]');
96+
await textarea.focus();
97+
await page.keyboard.press("Enter");
98+
99+
const errorMessage = page.getByText("Please enter a message");
100+
await expect(errorMessage).toBeVisible({ timeout: 5000 });
101+
102+
await textarea.fill("Test message");
103+
104+
await expect(errorMessage).not.toBeVisible({ timeout: 3000 });
105+
});
106+
107+
test("Navigation buttons work correctly", async ({ page }) => {
108+
const templateButton = page.getByRole("button", { name: "Start from Template" });
109+
await templateButton.click();
110+
await expect(page).toHaveURL(/templates-library/);
111+
112+
await page.goto("/ai");
113+
114+
const learnMoreButton = page.getByRole("button", { name: /Learn more/i });
115+
if (await learnMoreButton.isVisible()) {
116+
await learnMoreButton.click();
117+
await expect(page).toHaveURL(/intro/);
118+
}
119+
});
120+
});
121+
122+
test.describe("AI Chat Modal Iframe Behavior", () => {
123+
test("Modal can be closed", async ({ page }) => {
124+
await page.goto("/ai");
125+
await page.waitForLoadState("networkidle");
126+
127+
const textarea = page.locator('textarea[name="message"]');
128+
await textarea.fill("Test automation");
129+
await page.keyboard.press("Enter");
130+
131+
const modal = page.locator('[class*="modal"], [role="dialog"]');
132+
await expect(modal.first()).toBeVisible({ timeout: 10000 });
133+
134+
const closeButton = page.locator(
135+
'[aria-label*="close" i], [class*="close"], button:has-text("×"), button:has-text("Close")'
136+
);
137+
if (await closeButton.first().isVisible()) {
138+
await closeButton.first().click();
139+
await expect(modal.first()).not.toBeVisible({ timeout: 5000 });
140+
} else {
141+
await page.keyboard.press("Escape");
142+
await page.waitForTimeout(500);
143+
}
144+
});
145+
});

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
"test:e2e:rate-limited:serial": "E2E_RATE_LIMIT_DELAY=2000 playwright test --workers=1 --project=Chrome",
2727
"test:e2e:by-name": "node scripts/runTestByName.mjs",
2828
"test:e2e:by-name:serial:chrome": "node scripts/runTestByName.mjs --workers=1 --project=Chrome",
29+
"test:e2e:ui:file": "playwright test --ui --",
30+
"test:e2e:ui:dir": "playwright test --ui --",
2931
"test:e2e:serial": "playwright test --project=Chrome --workers=1",
3032
"test:e2e:chrome": "playwright test --project=Chrome --reporter=line",
3133
"test:e2e:firefox": "playwright test --project=Firefox --reporter=line",
@@ -215,4 +217,4 @@
215217
"rimraf": "^4.0.0",
216218
"micromatch": "^4.0.8"
217219
}
218-
}
220+
}

src/assets/index.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,21 @@ a {
139139
animation-play-state: running;
140140
}
141141

142+
@keyframes fadeIn {
143+
from {
144+
opacity: 0;
145+
transform: translateY(-10px);
146+
}
147+
to {
148+
opacity: 1;
149+
transform: translateY(0);
150+
}
151+
}
152+
153+
.animate-fade-in {
154+
animation: fadeIn 0.3s ease-out forwards;
155+
}
156+
142157
ul {
143158
font-size: 15px;
144159
@apply ml-6;

src/components/molecules/modal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export const Modal = ({
5858
: buttons.length >= 1
5959
? buttons.length - 1
6060
: undefined;
61-
if (!focusTabIndex || focusTabIndex < 0) return;
61+
if (focusTabIndex === undefined || focusTabIndex < 0) return;
6262
(buttons[focusTabIndex] as HTMLElement).focus();
6363

6464
const focusableElements = modalRef.current.querySelectorAll(

src/components/organisms/chatbotIframe/chatbotIframe.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,16 @@ import { ChatbotToolbar } from "./chatbotToolbar";
1010
import { iframeCommService } from "@services/iframeComm.service";
1111
import { LoggerService } from "@services/logger.service";
1212
import { aiChatbotUrl, defaultOpenedProjectFile, descopeProjectId, isDevelopment, namespaces } from "@src/constants";
13-
import { EventListenerName } from "@src/enums";
13+
import { EventListenerName, ModalName } from "@src/enums";
1414
import { triggerEvent, useChatbotIframeConnection, useEventListener } from "@src/hooks";
1515
import { ChatbotIframeProps } from "@src/interfaces/components";
16-
import { useOrganizationStore, useProjectStore, useSharedBetweenProjectsStore, useToastStore } from "@src/store";
16+
import {
17+
useModalStore,
18+
useOrganizationStore,
19+
useProjectStore,
20+
useSharedBetweenProjectsStore,
21+
useToastStore,
22+
} from "@src/store";
1723
import { MessageTypes } from "@src/types/iframeCommunication.type";
1824
import {
1925
cn,
@@ -199,12 +205,27 @@ export const ChatbotIframe = ({
199205
}
200206
});
201207

208+
const closeProjectCreationModalListener = iframeCommService.addListener(
209+
MessageTypes.CLOSE_PROJECT_CREATION_MODAL,
210+
() => {
211+
try {
212+
const currentPath = location.pathname;
213+
if (currentPath === "/welcome" || currentPath === "/ai") {
214+
useModalStore.getState().closeModal(ModalName.aiChat);
215+
}
216+
} catch (error) {
217+
console.error(namespaces.chatbot, t("errors.failedToCloseAiChatModal", { error }));
218+
}
219+
}
220+
);
221+
202222
return () => {
203223
LoggerService.debug(namespaces.chatbot, t("debug.cleanupListeners"));
204224

205225
iframeCommService.removeListener(directNavigationListener);
206226
iframeCommService.removeListener(directEventNavigationListener);
207227
iframeCommService.removeListener(varUpdatedListener);
228+
iframeCommService.removeListener(closeProjectCreationModalListener);
208229
};
209230
// eslint-disable-next-line react-hooks/exhaustive-deps
210231
}, []);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { ChatbotIframe } from "@components/organisms/chatbotIframe/chatbotIframe";
22
export { ChatbotToolbar } from "@components/organisms/chatbotIframe/chatbotToolbar";
33
export { ChatbotLoadingStates } from "@components/organisms/chatbotIframe/chatbotLoadingStates";
4+
export { isVarUpdatedMessage } from "@utilities/typeGuards/iframeCommunication.typeGuards";
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import React from "react";
2+
3+
import { useTranslation } from "react-i18next";
4+
5+
import { ModalName } from "@enums/components";
6+
7+
import { Button } from "@components/atoms";
8+
import { Modal } from "@components/molecules";
9+
import { ChatbotIframe } from "@components/organisms/chatbotIframe/chatbotIframe";
10+
11+
type AiChatModalProps = {
12+
isOpen: boolean;
13+
onClose: () => void;
14+
onConnect?: () => void;
15+
};
16+
17+
export const AiChatModal = ({ isOpen, onClose, onConnect }: AiChatModalProps) => {
18+
const { t: tAi } = useTranslation("dashboard", { keyPrefix: "ai" });
19+
20+
return (
21+
<Modal
22+
className="relative size-full border-none bg-black p-0"
23+
closeButtonClass="hidden"
24+
forceOpen={isOpen}
25+
hideCloseButton
26+
name={ModalName.aiChat}
27+
onCloseCallbackOverride={onClose}
28+
wrapperClass="p-2"
29+
>
30+
<Button
31+
aria-label={tAi("modal.closeLabel")}
32+
className="absolute right-3 top-3 z-10 bg-transparent p-1.5 hover:bg-gray-700 sm:right-6 sm:top-6"
33+
onClick={onClose}
34+
>
35+
<svg
36+
className="size-4 text-white sm:size-5"
37+
fill="none"
38+
stroke="currentColor"
39+
viewBox="0 0 24 24"
40+
xmlns="http://www.w3.org/2000/svg"
41+
>
42+
<path d="M6 18L18 6M6 6l12 12" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
43+
</svg>
44+
</Button>
45+
<ChatbotIframe
46+
className="size-full"
47+
hideCloseButton
48+
onConnect={onConnect}
49+
padded
50+
title={tAi("modal.assistantTitle")}
51+
/>
52+
</Modal>
53+
);
54+
};

0 commit comments

Comments
 (0)