Skip to content
Open
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
63 changes: 61 additions & 2 deletions web-app/src/hooks/__tests__/useAssistant.coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,29 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
import { act, renderHook } from '@testing-library/react'
import { useAssistant, defaultAssistant } from '../useAssistant'

const mockCreateAssistant = vi.fn().mockResolvedValue(undefined)
const mockDeleteAssistant = vi.fn().mockResolvedValue(undefined)
const mocks = vi.hoisted(() => ({
mockToast: vi.fn(),
mockCreateAssistant: vi.fn().mockResolvedValue(undefined),
mockDeleteAssistant: vi.fn().mockResolvedValue(undefined),
mockGetAssistants: vi.fn().mockResolvedValue([]),
}))
vi.mock('sonner', () => ({
toast: {
error: mocks.mockToast,
},
}))

const mockCreateAssistant = mocks.mockCreateAssistant
const mockDeleteAssistant = mocks.mockDeleteAssistant
const mockGetAssistants = mocks.mockGetAssistants
const mockToast = mocks.mockToast

vi.mock('@/hooks/useServiceHub', () => ({
getServiceHub: () => ({
assistants: () => ({
createAssistant: mockCreateAssistant,
deleteAssistant: mockDeleteAssistant,
getAssistants: mockGetAssistants,
}),
}),
}))
Expand All @@ -24,6 +39,7 @@ vi.mock('@/constants/localStorage', () => ({
describe('useAssistant - coverage', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.mockGetAssistants.mockResolvedValue([defaultAssistant])
localStorage.clear()
act(() => {
useAssistant.setState({
Expand Down Expand Up @@ -219,4 +235,47 @@ describe('useAssistant - coverage', () => {
// currentAssistant should still be jan
expect(result.current.currentAssistant?.id).toBe('jan')
})

it('refreshAssistants should fetch fresh data and update state', async () => {
const freshAssistants = [
{ ...defaultAssistant, name: 'Fresh Jan' },
{ id: 'a2', name: 'A2', avatar: '', description: '', instructions: '', created_at: 2, parameters: {} },
]
mockGetAssistants.mockResolvedValue(freshAssistants as any)

const { result } = renderHook(() => useAssistant())

expect(result.current.loading).toBe(true)

await act(async () => {
await result.current.refreshAssistants()
})

expect(result.current.assistants).toEqual(freshAssistants)
expect(result.current.loading).toBe(false)
})

it('refreshAssistants should set loading:true before fetch starts', async () => {
const deferred = vi.fn().mockReturnValue(new Promise((resolve) => setTimeout(resolve, 100)))
mockGetAssistants.mockReturnValue(deferred())

const { result } = renderHook(() => useAssistant())

expect(result.current.loading).toBe(true)
})

it('refreshAssistants should show toast error on failure', async () => {
mockGetAssistants.mockRejectedValue(new Error('Network error'))

const { result } = renderHook(() => useAssistant())

await act(async () => {
await result.current.refreshAssistants()
})

expect(mockToast).toHaveBeenCalledWith('Failed to refresh assistants', {
description: 'Network error',
})
expect(result.current.loading).toBe(false)
})
})
18 changes: 18 additions & 0 deletions web-app/src/hooks/useAssistant.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getServiceHub } from '@/hooks/useServiceHub'
import { Assistant as CoreAssistant } from '@janhq/core'
import { create } from 'zustand'
import { toast } from 'sonner'
import { localStorageKey } from '@/constants/localStorage'

interface AssistantState {
Expand All @@ -17,6 +18,7 @@ interface AssistantState {
) => void
setDefaultAssistant: (id: string) => void
setAssistants: (assistants: Assistant[] | null) => void
refreshAssistants: () => Promise<void>
}

const setLastUsedAssistantId = (assistantId: string) => {
Expand Down Expand Up @@ -208,4 +210,20 @@ export const useAssistant = create<AssistantState>((set, get) => ({
set({ loading: false })
}
},
refreshAssistants: async () => {
set({ loading: true })
try {
const assistants = await getServiceHub().assistants().getAssistants()
if (assistants) {
set({ assistants, loading: false })
} else {
set({ loading: false })
}
} catch (error) {
toast.error('Failed to refresh assistants', {
description: error instanceof Error ? error.message : String(error),
})
set({ loading: false })
}
},
}))
29 changes: 17 additions & 12 deletions web-app/src/routes/settings/assistant.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { route } from '@/constants/routes'
import { useState } from 'react'
import { useState, useCallback, useRef } from 'react'

import { useAssistant } from '@/hooks/useAssistant'

Expand Down Expand Up @@ -30,18 +30,19 @@ export const Route = createFileRoute(route.settings.assistant as any)({

function AssistantContent() {
const { t } = useTranslation()
const {
const {
assistants,
addAssistant,
updateAssistant,
deleteAssistant,
defaultAssistantId,
setDefaultAssistant
setDefaultAssistant,
} = useAssistant()
const [open, setOpen] = useState(false)
const [editingKey, setEditingKey] = useState<string | null>(null)
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [deletingId, setDeletingId] = useState<string | null>(null)
const refreshRequestRef = useRef(0)

const handleDelete = (id: string) => {
setDeletingId(id)
Expand All @@ -66,6 +67,17 @@ function AssistantContent() {
setEditingKey(null)
}

const handleEditClick = useCallback((assistantId: string) => {
// Before opening the edit dialog, refresh the assistants to get the latest data
const requestId = ++refreshRequestRef.current
useAssistant.getState().refreshAssistants()
.then(() => {
if (refreshRequestRef.current !== requestId) return
setEditingKey(assistantId)
setOpen(true)
})
}, [])

const sortedAssistants = assistants.slice().sort((a, b) => a.created_at - b.created_at)
const defaultAssistant = sortedAssistants.find((a) => a.id === defaultAssistantId)

Expand Down Expand Up @@ -174,10 +186,7 @@ function AssistantContent() {
variant="ghost"
size="icon-xs"
title={t('assistants:editAssistant')}
onClick={() => {
setEditingKey(assistant.id)
setOpen(true)
}}
onClick={() => handleEditClick(assistant.id)}
>
<IconPencil className="text-muted-foreground size-4" />
</Button>
Expand All @@ -198,11 +207,7 @@ function AssistantContent() {
open={open}
onOpenChange={setOpen}
editingKey={editingKey}
initialData={
editingKey
? assistants.find((a) => a.id === editingKey)
: undefined
}
initialData={editingKey ? assistants.find(a => a.id === editingKey) : undefined}
onSave={handleSave}
/>
<DeleteAssistantDialog
Expand Down