Skip to content

Commit b1b6328

Browse files
committed
feat: approval tool
1 parent f6636dc commit b1b6328

8 files changed

Lines changed: 645 additions & 211 deletions

File tree

src/components/Chat/ChatInterface.stories.tsx

Lines changed: 241 additions & 208 deletions
Large diffs are not rendered by default.

src/components/Chat/ChatInterface.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import {
3838
getDataPart,
3939
} from './types'
4040

41-
import { PartAuthenticateTool, PartText } from './PartTypes'
41+
import { PartAuthenticateTool, PartText, PartApprovalTool } from './PartTypes'
4242

4343
interface ChatInterfaceProps {
4444
messages: UIMessage[]
@@ -204,7 +204,7 @@ export function ChatInterface({
204204
// const textContent = getTextContent(message)
205205

206206
return (
207-
<div className={cn('flex items-start gap-3', isUser ? 'flex-row-reverse' : '')}>
207+
<div className={cn('flex my-2 items-start gap-3', isUser ? 'flex-row-reverse' : '')}>
208208
<Avatar className="h-8 w-8 shrink-0">
209209
<AvatarFallback
210210
className={cn(
@@ -321,6 +321,28 @@ export function ChatInterface({
321321
</div>
322322
)
323323
}
324+
if (part.type === 'tool-authenticate') {
325+
return (
326+
<div key={index} className="mb-2">
327+
<PartAuthenticateTool
328+
toolPart={toolPart}
329+
index={index}
330+
addToolResult={addToolResult ?? (() => {})}
331+
/>
332+
</div>
333+
)
334+
}
335+
if (part.type === 'tool-requestApproval') {
336+
return (
337+
<div key={index} className="mb-2">
338+
<PartApprovalTool
339+
toolPart={toolPart}
340+
index={index}
341+
addToolResult={addToolResult ?? (() => {})}
342+
/>
343+
</div>
344+
)
345+
}
324346

325347
// Handle artefact tool
326348
if (toolName === 'artefact') {
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import type { Meta, StoryObj } from '@storybook/react'
2+
import { action } from '@storybook/addon-actions'
3+
import { ApprovalRequestModal } from './ApprovalRequest'
4+
5+
const meta: Meta<typeof ApprovalRequestModal> = {
6+
title: 'Chat/Components/ApprovalRequestModal',
7+
component: ApprovalRequestModal,
8+
parameters: {
9+
layout: 'centered',
10+
docs: {
11+
description: {
12+
component: 'A modal component for requesting user approval with a reason. Used when explicit confirmation is needed before proceeding with an action.'
13+
}
14+
}
15+
},
16+
argTypes: {
17+
isOpen: {
18+
control: 'boolean',
19+
description: 'Controls whether the modal is visible'
20+
},
21+
reason: {
22+
control: 'text',
23+
description: 'The reason why approval is required (shown to the user)'
24+
},
25+
title: {
26+
control: 'text',
27+
description: 'The title of the modal'
28+
},
29+
onClose: {
30+
action: 'closed',
31+
description: 'Callback fired when the modal is closed'
32+
},
33+
onApprove: {
34+
action: 'approved',
35+
description: 'Callback fired when the user approves the action'
36+
},
37+
onDeny: {
38+
action: 'denied',
39+
description: 'Callback fired when the user denies the action'
40+
}
41+
}
42+
}
43+
44+
export default meta
45+
type Story = StoryObj<typeof ApprovalRequestModal>
46+
47+
export const Default: Story = {
48+
args: {
49+
isOpen: true,
50+
reason: 'This action will permanently delete all user data. Do you want to proceed?',
51+
onClose: action('onClose'),
52+
onApprove: action('onApprove'),
53+
onDeny: action('onDeny')
54+
}
55+
}
56+
57+
export const FileOperation: Story = {
58+
args: {
59+
isOpen: true,
60+
reason: 'This will overwrite the existing file "document.pdf". This action cannot be undone.',
61+
title: 'File Overwrite Confirmation',
62+
onClose: action('onClose'),
63+
onApprove: action('onApprove'),
64+
onDeny: action('onDeny')
65+
}
66+
}
67+
68+
export const DatabaseOperation: Story = {
69+
args: {
70+
isOpen: true,
71+
reason: 'You are about to execute a database migration that will affect 10,000+ records. This operation may take several minutes to complete.',
72+
title: 'Database Migration Approval',
73+
onClose: action('onClose'),
74+
onApprove: action('onApprove'),
75+
onDeny: action('onDeny')
76+
}
77+
}
78+
79+
export const PaymentOperation: Story = {
80+
args: {
81+
isOpen: true,
82+
reason: 'You are about to process a payment of $1,250.00 to vendor "Acme Corp". Please confirm this transaction.',
83+
title: 'Payment Authorization',
84+
onClose: action('onClose'),
85+
onApprove: action('onApprove'),
86+
onDeny: action('onDeny')
87+
}
88+
}
89+
90+
export const Closed: Story = {
91+
args: {
92+
isOpen: false,
93+
reason: 'This modal is closed and should not be visible',
94+
onClose: action('onClose'),
95+
onApprove: action('onApprove'),
96+
onDeny: action('onDeny')
97+
}
98+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest'
2+
import { render, screen, fireEvent } from '@testing-library/react'
3+
import { ApprovalRequestModal } from './ApprovalRequest'
4+
5+
describe('ApprovalRequestModal', () => {
6+
const mockProps = {
7+
reason: 'Test approval reason',
8+
onClose: vi.fn(),
9+
onApprove: vi.fn(),
10+
onDeny: vi.fn()
11+
}
12+
13+
beforeEach(() => {
14+
vi.clearAllMocks()
15+
})
16+
17+
it('renders when isOpen is true', () => {
18+
render(<ApprovalRequestModal {...mockProps} isOpen={true} />)
19+
20+
expect(screen.getByText('Approval Required')).toBeTruthy()
21+
expect(screen.getByText('Test approval reason')).toBeTruthy()
22+
})
23+
24+
it('does not render when isOpen is false', () => {
25+
render(<ApprovalRequestModal {...mockProps} isOpen={false} />)
26+
27+
expect(screen.queryByText('Approval Required')).toBeNull()
28+
})
29+
30+
it('displays custom title when provided', () => {
31+
render(<ApprovalRequestModal {...mockProps} title="Custom Title" isOpen={true} />)
32+
33+
expect(screen.getByText('Custom Title')).toBeTruthy()
34+
})
35+
36+
it('calls onApprove and onClose when Approve button is clicked', () => {
37+
render(<ApprovalRequestModal {...mockProps} isOpen={true} />)
38+
39+
const approveButton = screen.getByText('Approve')
40+
fireEvent.click(approveButton)
41+
42+
expect(mockProps.onApprove).toHaveBeenCalledTimes(1)
43+
expect(mockProps.onClose).toHaveBeenCalledTimes(1)
44+
})
45+
46+
it('calls onDeny and onClose when Deny button is clicked', () => {
47+
render(<ApprovalRequestModal {...mockProps} isOpen={true} />)
48+
49+
const denyButton = screen.getByText('Deny')
50+
fireEvent.click(denyButton)
51+
52+
expect(mockProps.onDeny).toHaveBeenCalledTimes(1)
53+
expect(mockProps.onClose).toHaveBeenCalledTimes(1)
54+
})
55+
56+
it('calls onClose when Cancel button is clicked', () => {
57+
render(<ApprovalRequestModal {...mockProps} isOpen={true} />)
58+
59+
const cancelButton = screen.getByText('Cancel')
60+
fireEvent.click(cancelButton)
61+
62+
expect(mockProps.onClose).toHaveBeenCalledTimes(1)
63+
expect(mockProps.onApprove).not.toHaveBeenCalled()
64+
expect(mockProps.onDeny).not.toHaveBeenCalled()
65+
})
66+
67+
it('disables buttons when processing', () => {
68+
render(<ApprovalRequestModal {...mockProps} isOpen={true} />)
69+
70+
const approveButton = screen.getByText('Approve')
71+
fireEvent.click(approveButton)
72+
73+
// After clicking, buttons should be disabled
74+
expect(screen.getByText('Processing...')).toBeTruthy()
75+
})
76+
77+
it('displays the reason text correctly', () => {
78+
const longReason = 'This is a very long reason that explains why approval is needed for this specific action that the user is about to perform.'
79+
render(<ApprovalRequestModal {...mockProps} reason={longReason} isOpen={true} />)
80+
81+
expect(screen.getByText(longReason)).toBeTruthy()
82+
})
83+
})
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
'use client'
2+
3+
import React, { useState } from 'react'
4+
5+
interface ApprovalRequestModalProps {
6+
isOpen?: boolean
7+
reason: string
8+
onClose: () => void
9+
onApprove: () => void
10+
onDeny: () => void
11+
title?: string
12+
}
13+
14+
const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
15+
isOpen = true,
16+
reason,
17+
onClose,
18+
onApprove,
19+
onDeny,
20+
title = 'Approval Required'
21+
}) => {
22+
const [isProcessing, setIsProcessing] = useState(false)
23+
24+
const handleApprove = () => {
25+
setIsProcessing(true)
26+
onApprove()
27+
onClose()
28+
}
29+
30+
const handleDeny = () => {
31+
setIsProcessing(true)
32+
onDeny()
33+
onClose()
34+
}
35+
36+
const handleCancel = () => {
37+
setIsProcessing(true)
38+
onDeny()
39+
onClose()
40+
}
41+
42+
if (!isOpen) return null
43+
44+
return (
45+
// <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[1000]">
46+
<div className="bg-white rounded-lg p-6 max-w-md w-full shadow-lg">
47+
<h3 className="text-lg font-bold mb-3 text-primary">
48+
{title}
49+
</h3>
50+
51+
<p className="text-sm text-gray-600 mb-6">
52+
{reason}
53+
</p>
54+
55+
<div className="flex gap-2">
56+
<button
57+
onClick={handleApprove}
58+
disabled={isProcessing}
59+
className={`
60+
px-4 py-2 text-white border-none rounded text-sm font-bold flex-1
61+
${isProcessing
62+
? 'bg-gray-400 cursor-not-allowed'
63+
: 'bg-green-600 hover:bg-green-700 cursor-pointer'
64+
}
65+
`}
66+
>
67+
{isProcessing ? 'Processing...' : 'Approve'}
68+
</button>
69+
70+
<button
71+
onClick={handleDeny}
72+
disabled={isProcessing}
73+
className={`
74+
px-4 py-2 text-white border-none rounded text-sm font-bold flex-1
75+
${isProcessing
76+
? 'bg-gray-400 cursor-not-allowed'
77+
: 'bg-red-600 hover:bg-red-700 cursor-pointer'
78+
}
79+
`}
80+
>
81+
{isProcessing ? 'Processing...' : 'Deny'}
82+
</button>
83+
84+
<button
85+
onClick={handleCancel}
86+
disabled={isProcessing}
87+
className={`
88+
px-4 py-2 text-white border-none rounded text-sm
89+
${isProcessing
90+
? 'bg-gray-400 cursor-not-allowed'
91+
: 'bg-gray-500 hover:bg-gray-600 cursor-pointer'
92+
}
93+
`}
94+
>
95+
Cancel
96+
</button>
97+
</div>
98+
</div>
99+
// </div>
100+
)
101+
}
102+
103+
export type { ApprovalRequestModalProps }
104+
export { ApprovalRequestModal }

0 commit comments

Comments
 (0)