Skip to content

Commit 0712c8e

Browse files
committed
feat: preview and create steps from chat
1 parent daeb6b2 commit 0712c8e

File tree

8 files changed

+217
-33
lines changed

8 files changed

+217
-33
lines changed

packages/frontend/src/components/AiBuilder/components/ChatInterface/ChatMessageToolbar.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { FaRegCopy, FaRegThumbsDown } from 'react-icons/fa'
22
import { FaCheck } from 'react-icons/fa6'
3+
import { MdOutlineRemoveRedEye } from 'react-icons/md'
34
import { Flex, Icon, IconButton, Tooltip } from '@chakra-ui/react'
45

56
import { Message } from '@/hooks/useChatStream'
@@ -24,6 +25,7 @@ export default function ChatMessageToolbar({
2425
index,
2526
copiedIndex,
2627
setCopiedIndex,
28+
onOpenDrawer,
2729
}: ChatMessageToolbarProps) {
2830
const handleCopy = async (text: string, index: number) => {
2931
await navigator.clipboard.writeText(text)
@@ -56,7 +58,15 @@ export default function ChatMessageToolbar({
5658
onClick={() => handleCopy(message.text, index)}
5759
/>
5860
</Tooltip>
59-
{/* TODO: Add preview button */}
61+
62+
<Tooltip label="Preview this workflow" placement="top">
63+
<IconButton
64+
{...DEFAULT_BUTTON_PROPS}
65+
aria-label="Preview this workflow"
66+
icon={<Icon as={MdOutlineRemoveRedEye} />}
67+
onClick={onOpenDrawer}
68+
/>
69+
</Tooltip>
6070
</Flex>
6171
)
6272
}

packages/frontend/src/components/AiBuilder/components/ChatInterface/ChatMessages.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { useState } from 'react'
2+
import { MdOutlineRemoveRedEye } from 'react-icons/md'
23
import { Box, Flex, Image, ImageProps, Text, VStack } from '@chakra-ui/react'
4+
import { Button } from '@opengovsg/design-system-react'
35

46
import plumberLogo from '@/assets/plumber-logo.svg'
57
import { Message } from '@/hooks/useChatStream'
@@ -56,6 +58,7 @@ export default function ChatMessages({
5658
isStreaming,
5759
messagesEndRef,
5860
messagesContainerRef,
61+
hasMessages,
5962
onOpenDrawer,
6063
}: ChatMessagesProps) {
6164
const [copiedIndex, setCopiedIndex] = useState<number | null>(null)
@@ -107,6 +110,26 @@ export default function ChatMessages({
107110
</Box>
108111
))}
109112

113+
{hasMessages && !isStreaming && (
114+
<Flex gap={3} w="full" align="start">
115+
<PlumberAvatar />
116+
<Box flex={1} color="gray.900">
117+
<ChakraStreamdown isAnimating={false}>
118+
Satisfied with your workflow?
119+
</ChakraStreamdown>
120+
<Button
121+
variant="outline"
122+
size="sm"
123+
onClick={onOpenDrawer}
124+
mt={2}
125+
leftIcon={<MdOutlineRemoveRedEye />}
126+
>
127+
Preview the steps
128+
</Button>
129+
</Box>
130+
</Flex>
131+
)}
132+
110133
{/* Streaming response */}
111134
{isStreaming && (
112135
<StreamingMessage currentResponse={currentResponse} />
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { FaTimes } from 'react-icons/fa'
2+
import { Box, Flex, Icon, IconButton, Text } from '@chakra-ui/react'
3+
import { useIsMobile } from '@opengovsg/design-system-react'
4+
5+
import StepsPreview from '../StepsPreview'
6+
7+
interface SideDrawerProps {
8+
isOpen: boolean
9+
onClose: () => void
10+
}
11+
12+
export default function SideDrawer({ isOpen, onClose }: SideDrawerProps) {
13+
const isMobile = useIsMobile()
14+
15+
return (
16+
<Box
17+
position="absolute"
18+
right={0}
19+
top={0}
20+
w={isMobile ? '100%' : '50%'}
21+
h="100%"
22+
bg="white"
23+
borderLeft={isMobile ? 'none' : '1px'}
24+
borderColor="gray.200"
25+
transform={isOpen ? 'translateX(0)' : 'translateX(100%)'}
26+
transition="transform 0.3s ease-in-out"
27+
zIndex={10}
28+
pointerEvents={isOpen ? 'auto' : 'none'}
29+
visibility={isOpen ? 'visible' : 'hidden'}
30+
>
31+
<Flex h="100%" flexDir="column" px={6} w="full">
32+
{/* Header */}
33+
<Flex justify="space-between" align="center" py={4}>
34+
<Text fontSize="xl" fontWeight="bold">
35+
Workflow preview
36+
</Text>
37+
<IconButton
38+
aria-label="Close drawer"
39+
icon={<Icon as={FaTimes} />}
40+
onClick={onClose}
41+
variant="clear"
42+
size="sm"
43+
/>
44+
</Flex>
45+
46+
{/* Content */}
47+
<Box flex={1} overflowY="auto" pb={4}>
48+
<StepsPreview />
49+
</Box>
50+
</Flex>
51+
</Box>
52+
)
53+
}

packages/frontend/src/components/AiBuilder/components/ChatInterface/index.tsx

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,30 @@
11
import { useEffect, useRef, useState } from 'react'
22
import { BiSolidMagicWand } from 'react-icons/bi'
33
import { IoChevronDown } from 'react-icons/io5'
4+
import { useNavigate } from 'react-router-dom'
45
import { Box, Flex, Icon, IconButton, Text } from '@chakra-ui/react'
5-
import { Tag } from '@opengovsg/design-system-react'
6+
import { Tag, useIsMobile } from '@opengovsg/design-system-react'
67

8+
import { parseWorkflow } from '@/components/AiBuilder/helpers/parseMarkdown'
9+
import * as URLS from '@/config/urls'
710
import { useChatStream } from '@/hooks/useChatStream'
811

12+
import { useAiBuilderContext } from '../AiBuilderContext'
13+
914
import ChatMessages from './ChatMessages'
1015
import PromptInput from './PromptInput'
16+
import SideDrawer from './SideDrawer'
1117

1218
export default function ChatInterface() {
19+
const navigate = useNavigate()
20+
const isMobile = useIsMobile()
21+
const { flowName, inputPrompt } = useAiBuilderContext()
22+
1323
const { messages, currentResponse, isStreaming, sendMessage, cancelStream } =
1424
useChatStream()
1525
const messagesEndRef = useRef<HTMLDivElement>(null)
1626
const messagesContainerRef = useRef<HTMLDivElement>(null)
27+
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
1728
const [showScrollButton, setShowScrollButton] = useState(false)
1829

1930
// Check if user is near bottom of scroll
@@ -58,7 +69,27 @@ export default function ChatInterface() {
5869
const hasMessages = messages.length > 0 || isStreaming
5970

6071
const handleOpenPreview = () => {
61-
// TODO: implement steps preview
72+
const { trigger, actions } = parseWorkflow(
73+
messages[messages.length - 1].text,
74+
)
75+
76+
// NOTE: only need to update the location state if there has been changes
77+
// if the user just closed and open the side drawer, we don't need to update
78+
// as we don't want to generate the ai steps again
79+
if (inputPrompt?.trigger !== trigger || inputPrompt?.actions !== actions) {
80+
navigate(`${URLS.EDITOR}/ai`, {
81+
state: {
82+
flowName,
83+
isFormMode: false,
84+
inputPrompt: {
85+
trigger,
86+
actions,
87+
},
88+
},
89+
replace: true,
90+
})
91+
}
92+
setIsDrawerOpen(true)
6293
}
6394

6495
// Initial empty state - centered layout
@@ -102,14 +133,15 @@ export default function ChatInterface() {
102133

103134
// Chat layout - messages with input at bottom
104135
return (
105-
<Flex h="100%" w="full" position="relative">
136+
<Flex h="100%" w="full" position="relative" overflow="hidden">
106137
{/* Main Chat Area */}
107138
<Flex
108139
h="100%"
109-
w="100%"
140+
w="full"
110141
flexDir="column"
111-
transition="width 0.3s ease-in-out"
112142
position="relative"
143+
pr={isDrawerOpen && !isMobile ? '50%' : '0'}
144+
transition="padding-right 0.3s ease-in-out"
113145
>
114146
{/* Messages Area */}
115147
<ChatMessages
@@ -162,7 +194,10 @@ export default function ChatInterface() {
162194
</Box>
163195
</Flex>
164196

165-
{/* TODO: Add Side Drawer */}
197+
<SideDrawer
198+
isOpen={isDrawerOpen}
199+
onClose={() => setIsDrawerOpen(false)}
200+
/>
166201
</Flex>
167202
)
168203
}

packages/frontend/src/components/AiBuilder/components/IdeaButtons.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Flex, Text } from '@chakra-ui/react'
2-
import { Button, FormLabel } from '@opengovsg/design-system-react'
2+
import { Button, FormLabel, useIsMobile } from '@opengovsg/design-system-react'
33

44
import { AiChatIdea, AiFormIdea } from '../constants'
55

@@ -9,13 +9,20 @@ interface IdeaButtonsProps {
99
}
1010

1111
export default function IdeaButtons({ ideas, onClick }: IdeaButtonsProps) {
12+
const isMobile = useIsMobile()
13+
1214
return (
1315
<Flex flexDir="column">
1416
<FormLabel isRequired>
1517
{/* arbitrary isRequired to hide optional text */}
1618
Need inspiration? Try one of these:
1719
</FormLabel>
18-
<Flex flexDir="row" gap={2} justifyContent="space-between">
20+
<Flex
21+
flexDir={isMobile ? 'row' : 'row'}
22+
gap={2}
23+
justifyContent="space-between"
24+
flexWrap="wrap"
25+
>
1926
{ideas.map((idea) => (
2027
<Button
2128
key={idea.label}
@@ -27,6 +34,8 @@ export default function IdeaButtons({ ideas, onClick }: IdeaButtonsProps) {
2734
bgColor: 'primary.200',
2835
}}
2936
onClick={() => onClick(idea)}
37+
w={isMobile ? 'calc(50% - 4px)' : 'auto'}
38+
flexShrink={0}
3039
>
3140
<Text textStyle="caption-1">{idea.label}</Text>
3241
</Button>

packages/frontend/src/components/AiBuilder/components/StepsPreview.tsx

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Fragment, useCallback, useEffect, useMemo } from 'react'
1+
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
22
import { useLocation, useNavigate } from 'react-router-dom'
33
import { useMutation } from '@apollo/client'
44
import {
@@ -89,8 +89,11 @@ export default function StepsPreview() {
8989
groupedSteps,
9090
stepGroupType,
9191
stepGroupCaption,
92+
isFormMode,
9293
} = useAiBuilderContext()
9394

95+
const [error, setError] = useState<boolean>(false)
96+
9497
const [generateAiStepsMutation, { loading: isGeneratingSteps }] =
9598
useMutation(GENERATE_AI_STEPS)
9699

@@ -99,22 +102,30 @@ export default function StepsPreview() {
99102
const navigate = useNavigate()
100103

101104
const generateAiSteps = useCallback(async () => {
102-
const { data } = await generateAiStepsMutation({
103-
variables: {
104-
input: {
105-
trigger: inputPrompt.trigger,
106-
actions: inputPrompt.actions,
105+
if (!inputPrompt) {
106+
return
107+
}
108+
109+
try {
110+
const { data } = await generateAiStepsMutation({
111+
variables: {
112+
input: {
113+
trigger: inputPrompt.trigger,
114+
actions: inputPrompt.actions,
115+
},
107116
},
108-
},
109-
})
117+
})
110118

111-
navigate(location.pathname, {
112-
state: {
113-
...location.state,
114-
output: data?.generateAiSteps,
115-
},
116-
replace: true, // Use replace to avoid adding to history
117-
})
119+
navigate(location.pathname, {
120+
state: {
121+
...location.state,
122+
output: data?.generateAiSteps,
123+
},
124+
replace: true, // Use replace to avoid adding to history
125+
})
126+
} catch {
127+
setError(true)
128+
}
118129
}, [
119130
generateAiStepsMutation,
120131
inputPrompt,
@@ -210,7 +221,19 @@ export default function StepsPreview() {
210221
if (isGeneratingSteps || !output) {
211222
return (
212223
<Center h="100%">
213-
{output && !isGeneratingSteps ? (
224+
{error ? (
225+
<Flex
226+
flexDir="column"
227+
alignItems="center"
228+
justifyContent="center"
229+
gap={4}
230+
w="100%"
231+
maxW="400px"
232+
>
233+
<Text>Something went wrong</Text>
234+
<Button onClick={() => setError(false)}>Try again</Button>
235+
</Flex>
236+
) : output && !isGeneratingSteps ? (
214237
<PrimarySpinner fontSize="4xl" />
215238
) : (
216239
<MultiStepLoader
@@ -296,12 +319,21 @@ export default function StepsPreview() {
296319
</GroupedStepContainer>
297320
)}
298321
<VStack mt={10} gap={2}>
299-
<Text textStyle="subhead-2">How does this workflow look?</Text>
322+
{isFormMode ? (
323+
<Text textStyle="subhead-2">How does this workflow look?</Text>
324+
) : (
325+
<Text textStyle="body-1">Looks good?</Text>
326+
)}
300327
<HStack alignItems="center" justifyContent="center" gap={2}>
301-
<Button variant="outline" onClick={onOpen}>
302-
Make changes
303-
</Button>
304-
<Button variant="outline" onClick={onCreateFlow}>
328+
{isFormMode && (
329+
<Button variant="outline" onClick={onOpen}>
330+
Make changes
331+
</Button>
332+
)}
333+
<Button
334+
variant={isFormMode ? 'outline' : 'solid'}
335+
onClick={onCreateFlow}
336+
>
305337
Create this workflow
306338
</Button>
307339
</HStack>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
function parseWorkflow(markdownText: string) {
2+
const result = {
3+
trigger: '',
4+
actions: '',
5+
}
6+
7+
// Extract trigger section (everything between "#### Start the workflow" and "#### Actions")
8+
const triggerMatch = markdownText.match(
9+
/#### Start the workflow\s+([\s\S]*?)#### Actions/,
10+
)
11+
if (triggerMatch) {
12+
result.trigger = triggerMatch[1].trim()
13+
}
14+
15+
// Extract actions section (everything after "#### Actions")
16+
const actionsMatch = markdownText.match(/#### Actions\s+([\s\S]*)/)
17+
if (actionsMatch) {
18+
result.actions = actionsMatch[1].trim()
19+
}
20+
21+
return result
22+
}
23+
24+
export { parseWorkflow }

0 commit comments

Comments
 (0)