Skip to content

Commit 0842fb6

Browse files
committed
feat: preview and create steps from chat
1 parent bd95efe commit 0842fb6

File tree

7 files changed

+206
-30
lines changed

7 files changed

+206
-30
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 & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,29 @@
11
import { useEffect, useRef, useState } from 'react'
22
import { IoChevronDown } from 'react-icons/io5'
3+
import { useNavigate } from 'react-router-dom'
34
import { Box, Flex, IconButton, Text } from '@chakra-ui/react'
5+
import { useIsMobile } from '@opengovsg/design-system-react'
46

7+
import { parseWorkflow } from '@/components/AiBuilder/helpers/parseMarkdown'
8+
import * as URLS from '@/config/urls'
59
import { useChatStream } from '@/hooks/useChatStream'
610

11+
import { useAiBuilderContext } from '../AiBuilderContext'
12+
713
import ChatMessages from './ChatMessages'
814
import PromptInput from './PromptInput'
15+
import SideDrawer from './SideDrawer'
916

1017
export default function ChatInterface() {
18+
const navigate = useNavigate()
19+
const isMobile = useIsMobile()
20+
const { flowName, inputPrompt } = useAiBuilderContext()
21+
1122
const { messages, currentResponse, isStreaming, sendMessage, cancelStream } =
1223
useChatStream()
1324
const messagesEndRef = useRef<HTMLDivElement>(null)
1425
const messagesContainerRef = useRef<HTMLDivElement>(null)
26+
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
1527
const [showScrollButton, setShowScrollButton] = useState(false)
1628

1729
// Check if user is near bottom of scroll
@@ -56,7 +68,27 @@ export default function ChatInterface() {
5668
const hasMessages = messages.length > 0 || isStreaming
5769

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

6294
// Initial empty state - centered layout
@@ -88,14 +120,15 @@ export default function ChatInterface() {
88120

89121
// Chat layout - messages with input at bottom
90122
return (
91-
<Flex h="100%" w="full" position="relative">
123+
<Flex h="100%" w="full" position="relative" overflow="hidden">
92124
{/* Main Chat Area */}
93125
<Flex
94126
h="100%"
95-
w="100%"
127+
w="full"
96128
flexDir="column"
97-
transition="width 0.3s ease-in-out"
98129
position="relative"
130+
pr={isDrawerOpen && !isMobile ? '50%' : '0'}
131+
transition="padding-right 0.3s ease-in-out"
99132
>
100133
{/* Messages Area */}
101134
<ChatMessages
@@ -148,7 +181,10 @@ export default function ChatInterface() {
148181
</Box>
149182
</Flex>
150183

151-
{/* TODO: Add Side Drawer */}
184+
<SideDrawer
185+
isOpen={isDrawerOpen}
186+
onClose={() => setIsDrawerOpen(false)}
187+
/>
152188
</Flex>
153189
)
154190
}

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 }

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ import { Box, Container, Flex, HStack, Icon, Text } from '@chakra-ui/react'
66
import * as URLS from '@/config/urls'
77
import InvalidEditorPage from '@/pages/Editor/components/InvalidEditorPage'
88

9-
import { EDITOR_MARGIN_TOP } from '../Editor/constants'
10-
119
import {
1210
AiBuilderContextProvider,
1311
useAiBuilderContext,
@@ -57,7 +55,7 @@ function AiBuilderContent() {
5755
maxW="full"
5856
px={0}
5957
py={isFormMode ? 10 : 0}
60-
mt={EDITOR_MARGIN_TOP}
58+
mt="51.5px"
6159
flex={1}
6260
overflowY="auto"
6361
sx={{

0 commit comments

Comments
 (0)