mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-06 21:35:52 +08:00
feat(sessions): add session creation modal and improve session item styling
- Implement SessionModal component for creating/editing sessions - Replace Button wrapper with fragment in SessionItem for cleaner styling - Add translation support and proper form handling for session creation
This commit is contained in:
parent
432d84cda5
commit
8ead4e9c0f
291
src/renderer/src/components/Popups/agent/SessionModal.tsx
Normal file
291
src/renderer/src/components/Popups/agent/SessionModal.tsx
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
cn,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
Select,
|
||||||
|
SelectedItemProps,
|
||||||
|
SelectedItems,
|
||||||
|
SelectItem,
|
||||||
|
Textarea,
|
||||||
|
useDisclosure
|
||||||
|
} from '@heroui/react'
|
||||||
|
import { loggerService } from '@logger'
|
||||||
|
import ClaudeIcon from '@renderer/assets/images/models/claude.png'
|
||||||
|
import { useSessions } from '@renderer/hooks/agents/useSessions'
|
||||||
|
import { AgentEntity, AgentSessionEntity, BaseSessionForm, CreateSessionForm, UpdateSessionForm } from '@renderer/types'
|
||||||
|
import { ChangeEvent, FormEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import { ErrorBoundary } from '../../ErrorBoundary'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('SessionAgentPopup')
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
// img src
|
||||||
|
avatar: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModelOption = Option
|
||||||
|
|
||||||
|
const buildSessionForm = (existing?: AgentSessionEntity, agent?: AgentEntity): BaseSessionForm => ({
|
||||||
|
name: existing?.name ?? agent?.name ?? 'Claude Code',
|
||||||
|
description: existing?.description ?? agent?.description,
|
||||||
|
instructions: existing?.instructions ?? agent?.instructions,
|
||||||
|
model: existing?.model ?? agent?.model ?? 'claude-4-sonnet',
|
||||||
|
accessible_paths: existing?.accessible_paths
|
||||||
|
? [...existing.accessible_paths]
|
||||||
|
: agent?.accessible_paths
|
||||||
|
? [...agent.accessible_paths]
|
||||||
|
: []
|
||||||
|
})
|
||||||
|
|
||||||
|
interface BaseProps {
|
||||||
|
agentId: string
|
||||||
|
session?: AgentSessionEntity
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TriggerProps extends BaseProps {
|
||||||
|
trigger: { content: ReactNode; className?: string }
|
||||||
|
isOpen?: never
|
||||||
|
onClose?: never
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateProps extends BaseProps {
|
||||||
|
trigger?: never
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = TriggerProps | StateProps
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal component for creating or editing a Session.
|
||||||
|
*
|
||||||
|
* Either trigger or isOpen and onClose is given.
|
||||||
|
* @param agentId - The ID of agent which the session is related.
|
||||||
|
* @param session - Optional session entity for editing mode.
|
||||||
|
* @param trigger - Optional trigger element that opens the modal. It MUST propagate the click event to trigger the modal.
|
||||||
|
* @param isOpen - Optional controlled modal open state. From useDisclosure.
|
||||||
|
* @param onClose - Optional callback when modal closes. From useDisclosure.
|
||||||
|
* @returns Modal component for agent creation/editing
|
||||||
|
*/
|
||||||
|
export const SessionModal: React.FC<Props> = ({ agentId, session, trigger, isOpen: _isOpen, onClose: _onClose }) => {
|
||||||
|
const { isOpen, onClose, onOpen } = useDisclosure({ isOpen: _isOpen, onClose: _onClose })
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const loadingRef = useRef(false)
|
||||||
|
// const { setTimeoutTimer } = useTimer()
|
||||||
|
const { createSession, updateSession } = useSessions(agentId)
|
||||||
|
const isEditing = (session?: AgentSessionEntity) => session !== undefined
|
||||||
|
|
||||||
|
const [form, setForm] = useState<BaseSessionForm>(() => buildSessionForm(session))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setForm(buildSessionForm(session))
|
||||||
|
}
|
||||||
|
}, [session, isOpen])
|
||||||
|
|
||||||
|
const Option = useCallback(
|
||||||
|
({ option }: { option?: Option | null }) => {
|
||||||
|
if (!option) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Avatar name="?" className="h-5 w-5" />
|
||||||
|
{t('common.invalid_value')}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Avatar src={option.avatar} className="h-5 w-5" />
|
||||||
|
{option.label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[t]
|
||||||
|
)
|
||||||
|
|
||||||
|
const Item = useCallback(({ item }: { item: SelectedItemProps<Option> }) => <Option option={item.data} />, [Option])
|
||||||
|
|
||||||
|
const renderOption = useCallback(
|
||||||
|
(items: SelectedItems<Option>) => items.map((item) => <Item key={item.key} item={item} />),
|
||||||
|
[Item]
|
||||||
|
)
|
||||||
|
|
||||||
|
const onNameChange = useCallback((name: string) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
name
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onDescChange = useCallback((description: string) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
description
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onInstChange = useCallback((instructions: string) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
instructions
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const modelOptions = useMemo(() => {
|
||||||
|
// mocked data. not final version
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'claude-4-sonnet',
|
||||||
|
label: 'Claude 4 Sonnet',
|
||||||
|
avatar: ClaudeIcon
|
||||||
|
}
|
||||||
|
] satisfies ModelOption[]
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onModelChange = useCallback((e: ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
model: e.target.value
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onSubmit = useCallback(
|
||||||
|
async (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (loadingRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingRef.current = true
|
||||||
|
|
||||||
|
// Additional validation check besides native HTML validation to ensure security
|
||||||
|
if (!form.model) {
|
||||||
|
window.toast.error(t('error.model.not_exists'))
|
||||||
|
loadingRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing(session)) {
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('Agent is required for editing mode')
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePayload = {
|
||||||
|
id: session.id,
|
||||||
|
name: form.name,
|
||||||
|
description: form.description,
|
||||||
|
instructions: form.instructions,
|
||||||
|
model: form.model
|
||||||
|
} satisfies UpdateSessionForm
|
||||||
|
|
||||||
|
updateSession(updatePayload)
|
||||||
|
logger.debug('Updated agent', updatePayload)
|
||||||
|
} else {
|
||||||
|
const newSession = {
|
||||||
|
name: form.name,
|
||||||
|
description: form.description,
|
||||||
|
instructions: form.instructions,
|
||||||
|
model: form.model,
|
||||||
|
accessible_paths: [...form.accessible_paths]
|
||||||
|
} satisfies CreateSessionForm
|
||||||
|
createSession(newSession)
|
||||||
|
logger.debug('Added agent', newSession)
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingRef.current = false
|
||||||
|
|
||||||
|
// setTimeoutTimer('onCreateAgent', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
[
|
||||||
|
form.model,
|
||||||
|
form.name,
|
||||||
|
form.description,
|
||||||
|
form.instructions,
|
||||||
|
form.accessible_paths,
|
||||||
|
session,
|
||||||
|
onClose,
|
||||||
|
t,
|
||||||
|
updateSession,
|
||||||
|
createSession
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
{/* NOTE: Hero UI Modal Pattern: Combine the Button and Modal components into a single
|
||||||
|
encapsulated component. This is because the Modal component needs to bind the onOpen
|
||||||
|
event handler to the Button for proper focus management.
|
||||||
|
|
||||||
|
Or just use external isOpen/onOpen/onClose to control modal state.
|
||||||
|
*/}
|
||||||
|
|
||||||
|
{trigger && (
|
||||||
|
<div
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onOpen()
|
||||||
|
}}
|
||||||
|
className={cn('w-full', trigger.className)}>
|
||||||
|
{trigger.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose}>
|
||||||
|
<ModalContent>
|
||||||
|
{(onClose) => (
|
||||||
|
<>
|
||||||
|
<ModalHeader>
|
||||||
|
{isEditing(session) ? t('agent.session.edit.title') : t('agent.session.add.title')}
|
||||||
|
</ModalHeader>
|
||||||
|
<Form onSubmit={onSubmit} className="w-full">
|
||||||
|
<ModalBody className="w-full">
|
||||||
|
<Input isRequired value={form.name} onValueChange={onNameChange} label={t('common.name')} />
|
||||||
|
{/* FIXME: Model type definition is string. It cannot be related to provider. Just mock a model now. */}
|
||||||
|
<Select
|
||||||
|
isRequired
|
||||||
|
selectionMode="single"
|
||||||
|
selectedKeys={form.model ? [form.model] : []}
|
||||||
|
onChange={onModelChange}
|
||||||
|
items={modelOptions}
|
||||||
|
label={t('common.model')}
|
||||||
|
placeholder={t('common.placeholders.select.model')}
|
||||||
|
renderValue={renderOption}>
|
||||||
|
{(option) => (
|
||||||
|
<SelectItem key={option.key} textValue={option.label}>
|
||||||
|
<Option option={option} />
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
<Textarea
|
||||||
|
label={t('common.description')}
|
||||||
|
value={form.description ?? ''}
|
||||||
|
onValueChange={onDescChange}
|
||||||
|
/>
|
||||||
|
{/* TODO: accessible paths */}
|
||||||
|
<Textarea label={t('common.prompt')} value={form.instructions ?? ''} onValueChange={onInstChange} />
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter className="w-full">
|
||||||
|
<Button onPress={onClose}>{t('common.close')}</Button>
|
||||||
|
<Button color="primary" type="submit" isLoading={loadingRef.current}>
|
||||||
|
{isEditing(session) ? t('common.confirm') : t('common.add')}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
</ErrorBoundary>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -28,9 +28,9 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, onDelete, onPress
|
|||||||
const SessionLabel = useCallback(() => {
|
const SessionLabel = useCallback(() => {
|
||||||
const displayName = session.name ?? session.id
|
const displayName = session.name ?? session.id
|
||||||
return (
|
return (
|
||||||
<Button>
|
<>
|
||||||
<span className="text-sm">{displayName}</span>
|
<span className="text-sm">{displayName}</span>
|
||||||
</Button>
|
</>
|
||||||
)
|
)
|
||||||
}, [session.id, session.name])
|
}, [session.id, session.name])
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
import { Spinner } from '@heroui/react'
|
import { Button, Spinner } from '@heroui/react'
|
||||||
|
import { SessionModal } from '@renderer/components/Popups/agent/SessionModal'
|
||||||
import { useSessions } from '@renderer/hooks/agents/useSessions'
|
import { useSessions } from '@renderer/hooks/agents/useSessions'
|
||||||
import { useAppDispatch } from '@renderer/store'
|
import { useAppDispatch } from '@renderer/store'
|
||||||
import { setActiveSessionIdAction } from '@renderer/store/runtime'
|
import { setActiveSessionIdAction } from '@renderer/store/runtime'
|
||||||
|
import { Plus } from 'lucide-react'
|
||||||
import { memo, useCallback } from 'react'
|
import { memo, useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import SessionItem from './SessionItem'
|
import SessionItem from './SessionItem'
|
||||||
|
|
||||||
@ -13,6 +16,7 @@ interface SessionsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
|
const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { sessions, isLoading, deleteSession } = useSessions(agentId)
|
const { sessions, isLoading, deleteSession } = useSessions(agentId)
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
@ -30,6 +34,19 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
|
|||||||
return (
|
return (
|
||||||
<div className="agents-tab h-full w-full p-2">
|
<div className="agents-tab h-full w-full p-2">
|
||||||
{/* TODO: Add session button */}
|
{/* TODO: Add session button */}
|
||||||
|
<SessionModal
|
||||||
|
agentId={agentId}
|
||||||
|
trigger={{
|
||||||
|
content: (
|
||||||
|
<Button
|
||||||
|
onPress={(e) => e.continuePropagation()}
|
||||||
|
className="mb-2 w-full justify-start bg-transparent text-foreground-500 hover:bg-accent">
|
||||||
|
<Plus size={16} className="mr-1 shrink-0" />
|
||||||
|
{t('agent.session.add.title')}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{sessions.map((session) => (
|
{sessions.map((session) => (
|
||||||
<SessionItem
|
<SessionItem
|
||||||
key={session.id}
|
key={session.id}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user