Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 75 additions & 40 deletions web-app/src/components/ui/__tests__/button.test.tsx
Original file line number Diff line number Diff line change
@@ -1,113 +1,134 @@
import React from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import userEvent from '@testing-library/user-event'
import '@testing-library/jest-dom'
import { Button } from '../button'

describe('Button', () => {
it('renders button with children', () => {
render(<Button>Click me</Button>)

expect(screen.getByRole('button')).toBeInTheDocument()
expect(screen.getByText('Click me')).toBeInTheDocument()
})

it('applies default variant classes', () => {
render(<Button>Default Button</Button>)

const button = screen.getByRole('button')
expect(button).toHaveClass('bg-primary', 'text-primary-fg', 'hover:bg-primary/90')
expect(button).toHaveClass(
'bg-primary',
'text-primary-fg',
'hover:bg-primary/90'
)
})

it('applies destructive variant classes', () => {
render(<Button variant="destructive">Destructive Button</Button>)

const button = screen.getByRole('button')
expect(button).toHaveClass('bg-destructive', 'text-destructive-fg', 'hover:bg-destructive/90')
expect(button).toHaveClass(
'bg-destructive',
'text-destructive-fg',
'hover:bg-destructive/90'
)
})

it('applies link variant classes', () => {
render(<Button variant="link">Link Button</Button>)

const button = screen.getByRole('button')
expect(button).toHaveClass('underline-offset-4', 'hover:no-underline')
})

it('applies default size classes', () => {
render(<Button>Default Size</Button>)

const button = screen.getByRole('button')
expect(button).toHaveClass('h-7', 'px-3', 'py-2')
})

it('applies small size classes', () => {
render(<Button size="sm">Small Button</Button>)

const button = screen.getByRole('button')
expect(button).toHaveClass('h-6', 'px-2')
})

it('applies large size classes', () => {
render(<Button size="lg">Large Button</Button>)

const button = screen.getByRole('button')
expect(button).toHaveClass('h-9', 'rounded-md', 'px-4')
})

it('applies icon size classes', () => {
render(<Button size="icon">Icon</Button>)

const button = screen.getByRole('button')
expect(button).toHaveClass('size-8')
})

it('handles click events', async () => {
const handleClick = vi.fn()
const user = userEvent.setup()

render(<Button onClick={handleClick}>Click me</Button>)

await user.click(screen.getByRole('button'))

expect(handleClick).toHaveBeenCalledTimes(1)
})

it('can be disabled', () => {
render(<Button disabled>Disabled Button</Button>)

const button = screen.getByRole('button')
expect(button).toBeDisabled()
expect(button).toHaveClass('disabled:pointer-events-none', 'disabled:opacity-50')
expect(button).toHaveClass(
'disabled:pointer-events-none',
'disabled:opacity-50'
)
})

it('does not trigger click when disabled', async () => {
const handleClick = vi.fn()
const user = userEvent.setup()

render(<Button disabled onClick={handleClick}>Disabled Button</Button>)


render(
<Button disabled onClick={handleClick}>
Disabled Button
</Button>
)

await user.click(screen.getByRole('button'))

expect(handleClick).not.toHaveBeenCalled()
})

it('forwards ref correctly', () => {
const ref = vi.fn()

render(<Button ref={ref}>Button with ref</Button>)

expect(ref).toHaveBeenCalledWith(expect.any(HTMLButtonElement))
})

it('accepts custom className', () => {
render(<Button className="custom-class">Custom Button</Button>)

const button = screen.getByRole('button')
expect(button).toHaveClass('custom-class')
})

it('accepts custom props', () => {
render(<Button data-testid="custom-button" type="submit">Custom Button</Button>)

render(
<Button data-testid="custom-button" type="submit">
Custom Button
</Button>
)

const button = screen.getByTestId('custom-button')
expect(button).toHaveAttribute('type', 'submit')
})
Expand All @@ -118,51 +139,65 @@ describe('Button', () => {
<a href="/test">Link Button</a>
</Button>
)

const link = screen.getByRole('link')
expect(link).toHaveAttribute('href', '/test')
expect(link).toHaveClass('bg-primary', 'text-primary-fg') // Should inherit button classes
})

it('combines variant and size classes correctly', () => {
render(<Button variant="destructive" size="lg">Large Destructive Button</Button>)

render(
<Button variant="destructive" size="lg">
Large Destructive Button
</Button>
)

const button = screen.getByRole('button')
expect(button).toHaveClass('bg-destructive', 'text-destructive-fg') // destructive variant
expect(button).toHaveClass('h-9', 'rounded-md', 'px-4') // large size
})

it('handles keyboard events', () => {
const handleKeyDown = vi.fn()

render(<Button onKeyDown={handleKeyDown}>Keyboard Button</Button>)

const button = screen.getByRole('button')
fireEvent.keyDown(button, { key: 'Enter' })

expect(handleKeyDown).toHaveBeenCalledWith(expect.objectContaining({
key: 'Enter'
}))

expect(handleKeyDown).toHaveBeenCalledWith(
expect.objectContaining({
key: 'Enter',
})
)
})

it('supports focus events', () => {
const handleFocus = vi.fn()
const handleBlur = vi.fn()

render(<Button onFocus={handleFocus} onBlur={handleBlur}>Focus Button</Button>)


render(
<Button onFocus={handleFocus} onBlur={handleBlur}>
Focus Button
</Button>
)

const button = screen.getByRole('button')
fireEvent.focus(button)
fireEvent.blur(button)

expect(handleFocus).toHaveBeenCalledTimes(1)
expect(handleBlur).toHaveBeenCalledTimes(1)
})

it('applies focus-visible styling', () => {
render(<Button>Focus Button</Button>)

const button = screen.getByRole('button')
expect(button).toHaveClass('focus-visible:border-ring', 'focus-visible:ring-ring/50')
expect(button).toHaveClass(
'focus-visible:border-primary',
'focus-visible:ring-2',
'focus-visible:ring-primary/60'
)
})
})
})
11 changes: 8 additions & 3 deletions web-app/src/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'

const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[0px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer focus:outline-none",
cn(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none aria-invalid:ring-destructive/60 aria-invalid:border-destructive cursor-pointer",
'focus:border-accent focus:ring-2 focus:ring-accent/50 focus:accent-[0px]',
'focus-visible:border-accent focus-visible:ring-2 focus-visible:ring-accent/50 focus-visible:accent-[0px]'
),
{
variants: {
variant: {
default: 'bg-primary text-primary-fg shadow-xs hover:bg-primary/90',
default:
'bg-primary text-primary-fg shadow-xs hover:bg-primary/90 focus-visible:ring-primary/60 focus:ring-primary/60 focus:border-primary focus-visible:border-primary',
destructive:
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive-fg',
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/60 text-destructive-fg focus:border-destructive focus:ring-destructive/60',
link: 'underline-offset-4 hover:no-underline',
},
size: {
Expand Down
4 changes: 2 additions & 2 deletions web-app/src/containers/ThreadContent.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ThreadMessage } from '@janhq/core'
import { RenderMarkdown } from './RenderMarkdown'
import React, { Fragment, memo, useCallback, useMemo, useState } from 'react'
import {

Check warning on line 4 in web-app/src/containers/ThreadContent.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

2-4 lines are not covered with tests
IconCopy,
IconCopyCheck,
IconRefresh,
Expand All @@ -9,13 +9,13 @@
IconPencil,
IconInfoCircle,
} from '@tabler/icons-react'
import { useAppState } from '@/hooks/useAppState'
import { cn } from '@/lib/utils'
import { useMessages } from '@/hooks/useMessages'
import ThinkingBlock from '@/containers/ThinkingBlock'
import ToolCallBlock from '@/containers/ToolCallBlock'
import { useChat } from '@/hooks/useChat'
import {

Check warning on line 18 in web-app/src/containers/ThreadContent.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

12-18 lines are not covered with tests
Dialog,
DialogClose,
DialogContent,
Expand All @@ -24,42 +24,42 @@
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import {

Check warning on line 29 in web-app/src/containers/ThreadContent.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

27-29 lines are not covered with tests
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { formatDate } from '@/utils/formatDate'
import { AvatarEmoji } from '@/containers/AvatarEmoji'

Check warning on line 35 in web-app/src/containers/ThreadContent.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

34-35 lines are not covered with tests

import TokenSpeedIndicator from '@/containers/TokenSpeedIndicator'

Check warning on line 37 in web-app/src/containers/ThreadContent.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

37 line is not covered with tests

import CodeEditor from '@uiw/react-textarea-code-editor'
import '@uiw/react-textarea-code-editor/dist.css'
import { useTranslation } from '@/i18n/react-i18next-compat'

Check warning on line 41 in web-app/src/containers/ThreadContent.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

39-41 lines are not covered with tests

const CopyButton = ({ text }: { text: string }) => {
const [copied, setCopied] = useState(false)
const { t } = useTranslation()

Check warning on line 45 in web-app/src/containers/ThreadContent.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

43-45 lines are not covered with tests

const handleCopy = () => {
navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}

Check warning on line 51 in web-app/src/containers/ThreadContent.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

47-51 lines are not covered with tests

return (
<button
className="flex items-center gap-1 hover:text-accent transition-colors group relative cursor-pointer"
onClick={handleCopy}

Check warning on line 56 in web-app/src/containers/ThreadContent.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

53-56 lines are not covered with tests
>
{copied ? (
<>
<IconCopyCheck size={16} className="text-accent" />
<span className="opacity-100">{t('copied')}</span>
</>

Check warning on line 62 in web-app/src/containers/ThreadContent.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

58-62 lines are not covered with tests
) : (
<Tooltip>
<TooltipTrigger asChild>
Expand Down Expand Up @@ -104,13 +104,13 @@
</TooltipContent>
</Tooltip>
</DialogTrigger>
<DialogContent className="w-3/4 h-3/4">
<DialogContent className="w-3/4">
<DialogHeader>
<DialogTitle>{t('common:dialogs.editMessage.title')}</DialogTitle>
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
className="mt-2 resize-none h-full w-full"
className="mt-2 resize-none w-full"
onKeyDown={(e) => {
// Prevent key from being captured by parent components
e.stopPropagation()
Expand Down
19 changes: 16 additions & 3 deletions web-app/src/containers/dialogs/ToolApproval.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default function ToolApproval() {
return null
}

const { toolName, onApprove, onDeny } = modalProps
const { toolName, toolParameters, onApprove, onDeny } = modalProps

const handleAllowOnce = () => {
onApprove(true) // true = allow once only
Expand Down Expand Up @@ -58,7 +58,20 @@ export default function ToolApproval() {
</div>
</DialogHeader>

<div className="bg-main-view-fg/8 p-2 border border-main-view-fg/5 rounded-lg">
{toolParameters && Object.keys(toolParameters).length > 0 && (
<div className="bg-main-view-fg/4 p-2 border border-main-view-fg/5 rounded-lg">
<h4 className="text-sm font-medium text-main-view-fg mb-2">
{t('tools:toolApproval.parameters')}
</h4>
<div className="bg-main-view-fg/6 rounded-md p-2 text-sm font-mono border border-main-view-fg/5">
<pre className="text-main-view-fg/80 whitespace-pre-wrap break-words">
{JSON.stringify(toolParameters, null, 2)}
</pre>
</div>
</div>
)}

<div className="bg-main-view-fg/1 p-2 border border-main-view-fg/5 rounded-lg">
<p className="text-sm text-main-view-fg/70 leading-relaxed">
{t('tools:toolApproval.securityNotice')}
</p>
Expand All @@ -80,7 +93,7 @@ export default function ToolApproval() {
>
{t('tools:toolApproval.allowOnce')}
</Button>
<Button variant="default" onClick={handleAllow}>
<Button variant="default" onClick={handleAllow} autoFocus>
{t('tools:toolApproval.alwaysAllow')}
</Button>
</div>
Expand Down
6 changes: 4 additions & 2 deletions web-app/src/hooks/useToolApproval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { localStorageKey } from '@/constants/localStorage'
export type ToolApprovalModalProps = {
toolName: string
threadId: string
toolParameters?: object
onApprove: (allowOnce: boolean) => void
onDeny: () => void
}
Expand All @@ -21,7 +22,7 @@ type ToolApprovalState = {
// Actions
approveToolForThread: (threadId: string, toolName: string) => void
isToolApproved: (threadId: string, toolName: string) => boolean
showApprovalModal: (toolName: string, threadId: string) => Promise<boolean>
showApprovalModal: (toolName: string, threadId: string, toolParameters?: object) => Promise<boolean>
closeModal: () => void
setModalOpen: (open: boolean) => void
setAllowAllMCPPermissions: (allow: boolean) => void
Expand Down Expand Up @@ -52,7 +53,7 @@ export const useToolApproval = create<ToolApprovalState>()(
return state.approvedTools[threadId]?.includes(toolName) || false
},

showApprovalModal: (toolName: string, threadId: string) => {
showApprovalModal: (toolName: string, threadId: string, toolParameters?: object) => {
return new Promise<boolean>((resolve) => {
// Check if tool is already approved for this thread
const state = get()
Expand All @@ -66,6 +67,7 @@ export const useToolApproval = create<ToolApprovalState>()(
modalProps: {
toolName,
threadId,
toolParameters,
onApprove: (allowOnce: boolean) => {
if (!allowOnce) {
// If not "allow once", add to approved tools for this thread
Expand Down
20 changes: 18 additions & 2 deletions web-app/src/lib/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,11 @@ export const postMessageProcessing = async (
message: ThreadMessage,
abortController: AbortController,
approvedTools: Record<string, string[]> = {},
showModal?: (toolName: string, threadId: string) => Promise<boolean>,
showModal?: (
toolName: string,
threadId: string,
toolParameters?: object
) => Promise<boolean>,
allowAllMCPPermissions: boolean = false
) => {
// Handle completed tool calls
Expand Down Expand Up @@ -358,11 +362,23 @@ export const postMessageProcessing = async (
}

// Check if tool is approved or show modal for approval
let toolParameters = {}
if (toolCall.function.arguments.length) {
try {
toolParameters = JSON.parse(toolCall.function.arguments)
} catch (error) {
console.error('Failed to parse tool arguments:', error)
}
}
const approved =
allowAllMCPPermissions ||
approvedTools[message.thread_id]?.includes(toolCall.function.name) ||
(showModal
? await showModal(toolCall.function.name, message.thread_id)
? await showModal(
toolCall.function.name,
message.thread_id,
toolParameters
)
: true)

let result = approved
Expand Down
3 changes: 2 additions & 1 deletion web-app/src/locales/de-DE/tool-approval.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@
"alwaysAllow": "Immer erlauben",
"permissions": "Berechtigungen",
"approve": "Genehmigen",
"reject": "Ablehnen"
"reject": "Ablehnen",
"parameters": "Werkzeug-Parameter"
}
Loading
Loading