mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-03 12:27:41 +08:00
refactor(pages): v2 re-skin knowledge/notes/apps/library/translate/paintings/openclaw
Signed-off-by: Siin Xu <31815270+SiinXu@users.noreply.github.com>
This commit is contained in:
@@ -56,7 +56,7 @@ sessionActionRegistry.registerAction({
|
||||
id: 'session.rename',
|
||||
commandId: 'session.rename',
|
||||
label: ({ t }) => t('common.rename'),
|
||||
icon: () => <EditIcon size={14} />,
|
||||
icon: () => <EditIcon className="size-3.5" strokeWidth={1.6} />,
|
||||
order: 10,
|
||||
surface: 'menu'
|
||||
})
|
||||
@@ -65,7 +65,8 @@ sessionActionRegistry.registerAction({
|
||||
id: 'session.toggle-pin',
|
||||
commandId: 'session.toggle-pin',
|
||||
label: ({ pinned, t }) => (pinned ? t('agent.session.unpin.title') : t('agent.session.pin.title')),
|
||||
icon: ({ pinned }) => (pinned ? <PinOffIcon size={14} /> : <PinIcon size={14} />),
|
||||
icon: ({ pinned }) =>
|
||||
pinned ? <PinOffIcon className="size-3.5" strokeWidth={1.6} /> : <PinIcon className="size-3.5" strokeWidth={1.6} />,
|
||||
order: 20,
|
||||
surface: 'menu'
|
||||
})
|
||||
@@ -74,7 +75,7 @@ sessionActionRegistry.registerAction({
|
||||
id: 'session.open-in-new-tab',
|
||||
commandId: 'session.open-in-new-tab',
|
||||
label: ({ t }) => t('common.open_in_new_tab'),
|
||||
icon: () => <ExternalLink size={14} />,
|
||||
icon: () => <ExternalLink className="size-3.5" strokeWidth={1.6} />,
|
||||
order: 30,
|
||||
surface: 'menu'
|
||||
})
|
||||
@@ -83,7 +84,7 @@ sessionActionRegistry.registerAction({
|
||||
id: 'session.open-in-new-window',
|
||||
commandId: 'session.open-in-new-window',
|
||||
label: ({ t }) => t('tab.open_in_new_window'),
|
||||
icon: () => <OpenInNewWindowIcon size={14} />,
|
||||
icon: () => <OpenInNewWindowIcon className="size-3.5" strokeWidth={1.6} />,
|
||||
order: 35,
|
||||
surface: 'menu'
|
||||
})
|
||||
@@ -92,7 +93,7 @@ sessionActionRegistry.registerAction({
|
||||
id: 'session.delete',
|
||||
commandId: 'session.delete',
|
||||
label: ({ t }) => t('common.delete'),
|
||||
icon: () => <DeleteIcon size={14} className="lucide-custom" />,
|
||||
icon: () => <DeleteIcon className="size-3.5 lucide-custom" strokeWidth={1.6} />,
|
||||
group: 'danger',
|
||||
order: 40,
|
||||
surface: 'menu',
|
||||
|
||||
@@ -83,7 +83,7 @@ const CreateKnowledgeBaseDialogActions = ({
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button type="submit" variant="emphasis" loading={isCreating}>
|
||||
<Button type="submit" variant="default" loading={isCreating}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</KnowledgeDialogFooter>
|
||||
@@ -164,7 +164,9 @@ const CreateKnowledgeBaseDialogRoot = ({
|
||||
<CreateKnowledgeBaseDialog.Form onSubmit={handleSubmit}>
|
||||
<KnowledgeDialogBody>
|
||||
<KnowledgeDialogField>
|
||||
<Label htmlFor="knowledge-create-name">{t('common.name')}</Label>
|
||||
<Label htmlFor="knowledge-create-name" className="text-xs font-normal">
|
||||
{t('common.name')}
|
||||
</Label>
|
||||
<Input
|
||||
id="knowledge-create-name"
|
||||
value={values.name}
|
||||
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from '@cherrystudio/ui'
|
||||
import type { KnowledgeBase } from '@shared/data/types/knowledge'
|
||||
import { FlaskConical, MoreHorizontal, PencilLine, SlidersHorizontal, Trash2 } from 'lucide-react'
|
||||
import { formatRelativeTime } from '@renderer/utils/time'
|
||||
import type { KnowledgeBase, KnowledgeItemType } from '@shared/data/types/knowledge'
|
||||
import { FlaskConical, MoreHorizontal, PencilLine, Plus, SlidersHorizontal, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { KNOWLEDGE_DATA_SOURCE_TYPES } from './addKnowledgeItemDialog/constants'
|
||||
import { statusBadgeClassNames } from './statusStyles'
|
||||
|
||||
interface DetailHeaderProps {
|
||||
@@ -22,6 +24,7 @@ interface DetailHeaderProps {
|
||||
onRenameBase: (base: Pick<KnowledgeBase, 'id' | 'name'>) => void
|
||||
onDeleteBase: (baseId: string) => Promise<void> | void
|
||||
onRebuild: () => void
|
||||
onAddSource: (source: KnowledgeItemType) => void
|
||||
}
|
||||
|
||||
const DetailHeader = ({
|
||||
@@ -30,11 +33,13 @@ const DetailHeader = ({
|
||||
onOpenRecallTest,
|
||||
onRenameBase,
|
||||
onDeleteBase,
|
||||
onRebuild
|
||||
onRebuild,
|
||||
onAddSource
|
||||
}: DetailHeaderProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [isSourceMenuOpen, setIsSourceMenuOpen] = useState(false)
|
||||
|
||||
const statusLabelKey = `knowledge.status.${base.status}` as const
|
||||
const statusLabel = t(statusLabelKey)
|
||||
@@ -52,36 +57,84 @@ const DetailHeader = ({
|
||||
setIsDeleteDialogOpen(false)
|
||||
}, [base.id, onDeleteBase])
|
||||
|
||||
const handleSourceSelect = useCallback(
|
||||
(source: KnowledgeItemType) => {
|
||||
setIsSourceMenuOpen(false)
|
||||
onAddSource(source)
|
||||
},
|
||||
[onAddSource]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="shrink-0 px-3 pt-3.5 pb-2">
|
||||
<div className="flex min-w-0 items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h1 className="min-w-0 truncate font-bold text-2xl text-foreground leading-8">{base.name}</h1>
|
||||
{base.status === 'failed' ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onRebuild}
|
||||
aria-label={`${statusLabel}, ${t('knowledge.restore.action')}`}
|
||||
title={t('knowledge.restore.action')}
|
||||
className="h-auto min-h-0 shrink-0 cursor-pointer rounded-full p-0 shadow-none transition-opacity hover:bg-transparent hover:opacity-80">
|
||||
<Badge variant="outline" className={statusBadgeClassNames[base.status]}>
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h1 className="min-w-0 truncate font-[550] text-xl text-foreground leading-7">{base.name}</h1>
|
||||
{base.status === 'failed' ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onRebuild}
|
||||
aria-label={`${statusLabel}, ${t('knowledge.restore.action')}`}
|
||||
title={t('knowledge.restore.action')}
|
||||
className="h-auto min-h-0 shrink-0 cursor-pointer rounded-full p-0 shadow-none transition-opacity hover:bg-transparent hover:opacity-80">
|
||||
<Badge variant="outline" className={statusBadgeClassNames[base.status]}>
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
</Button>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${statusBadgeClassNames[base.status]} shrink-0`}
|
||||
aria-label={statusLabel}
|
||||
title={statusLabel}>
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
</Button>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${statusBadgeClassNames[base.status]} shrink-0`}
|
||||
aria-label={statusLabel}
|
||||
title={statusLabel}>
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
)}
|
||||
)}
|
||||
<span className="shrink-0 text-foreground-muted text-xs">
|
||||
{t('knowledge.meta.updated_at', { time: formatRelativeTime(base.updatedAt, i18n.language) })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Popover open={isSourceMenuOpen} onOpenChange={setIsSourceMenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isSourceMenuOpen}
|
||||
className="min-h-0 rounded-lg px-3 py-1.5 text-foreground/80 shadow-none hover:bg-accent hover:text-foreground [&_svg]:[stroke-width:1.6]">
|
||||
<Plus className="size-3.5" />
|
||||
{t('knowledge.data_source.toolbar.add')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
collisionPadding={8}
|
||||
className="w-[var(--radix-popover-trigger-width)] rounded-xl p-1.5"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}>
|
||||
<MenuList role="menu" className="gap-1">
|
||||
{KNOWLEDGE_DATA_SOURCE_TYPES.map((source) => (
|
||||
<MenuItem
|
||||
key={source.value}
|
||||
role="menuitem"
|
||||
variant="ghost"
|
||||
label={t(source.labelKey)}
|
||||
className="h-8 rounded-lg px-2.5 text-sm"
|
||||
onClick={() => handleSourceSelect(source.value)}
|
||||
/>
|
||||
))}
|
||||
</MenuList>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{base.status !== 'failed' && (
|
||||
<>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onOpenRecallTest}>
|
||||
@@ -94,7 +147,7 @@ const DetailHeader = ({
|
||||
size="icon-sm"
|
||||
aria-label={t('knowledge.tabs.rag_config')}
|
||||
onClick={onOpenRagConfig}>
|
||||
<SlidersHorizontal size={14} />
|
||||
<SlidersHorizontal size={14} strokeWidth={1.6} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -102,7 +102,7 @@ const KnowledgeEntityNameDialog = ({
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" variant="emphasis" loading={isSubmitting}>
|
||||
<Button type="submit" variant="default" loading={isSubmitting}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</KnowledgeDialogFooter>
|
||||
|
||||
@@ -64,8 +64,8 @@ export const KnowledgeModelSelect = ({
|
||||
aria-label={ariaLabel}
|
||||
aria-invalid={invalid || undefined}
|
||||
className={cn(
|
||||
'h-8 w-full justify-between gap-2 rounded-md px-3 font-normal text-sm shadow-none',
|
||||
'aria-expanded:border-primary aria-expanded:ring-3 aria-expanded:ring-primary/20',
|
||||
'h-7 w-full justify-between gap-2 rounded-lg border-transparent bg-muted/50 px-2.5 font-normal text-sm shadow-none hover:bg-muted',
|
||||
'aria-expanded:bg-muted',
|
||||
hasValue ? 'text-foreground' : 'text-muted-foreground',
|
||||
invalid && 'border-destructive aria-expanded:ring-red-600/20'
|
||||
)}>
|
||||
|
||||
@@ -5,6 +5,10 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import DetailHeader from '../DetailHeader'
|
||||
|
||||
vi.mock('@renderer/utils/time', () => ({
|
||||
formatRelativeTime: () => '刚刚'
|
||||
}))
|
||||
|
||||
vi.mock('@cherrystudio/ui', async () => {
|
||||
const React = await import('react')
|
||||
const PopoverContext = React.createContext<{
|
||||
@@ -92,6 +96,7 @@ vi.mock('@cherrystudio/ui', async () => {
|
||||
onClick?: (event: React.MouseEvent) => void
|
||||
}>
|
||||
|
||||
// eslint-disable-next-line @eslint-react/no-clone-element
|
||||
return React.cloneElement(child, {
|
||||
onClick: (event: React.MouseEvent) => {
|
||||
child.props.onClick?.(event)
|
||||
@@ -121,6 +126,11 @@ vi.mock('react-i18next', () => ({
|
||||
'common.clear': '清除',
|
||||
'common.delete': '删除',
|
||||
'common.more': '更多',
|
||||
'knowledge.data_source.toolbar.add': '添加数据源',
|
||||
'knowledge.data_source.add_dialog.sources.file': '文件',
|
||||
'knowledge.data_source.add_dialog.sources.note': '笔记',
|
||||
'knowledge.data_source.add_dialog.sources.directory': '目录',
|
||||
'knowledge.data_source.add_dialog.sources.url': '链接',
|
||||
'knowledge.context.delete': '删除知识库',
|
||||
'knowledge.context.delete_confirm_description': '删除后无法恢复',
|
||||
'knowledge.context.delete_confirm_title': '确认删除知识库',
|
||||
@@ -172,6 +182,7 @@ describe('DetailHeader', () => {
|
||||
onRenameBase={vi.fn()}
|
||||
onDeleteBase={vi.fn()}
|
||||
onRebuild={vi.fn()}
|
||||
onAddSource={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -191,6 +202,7 @@ describe('DetailHeader', () => {
|
||||
onRenameBase={vi.fn()}
|
||||
onDeleteBase={vi.fn()}
|
||||
onRebuild={onRebuild}
|
||||
onAddSource={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -224,6 +236,7 @@ describe('DetailHeader', () => {
|
||||
onRenameBase={vi.fn()}
|
||||
onDeleteBase={vi.fn()}
|
||||
onRebuild={onRebuild}
|
||||
onAddSource={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -243,6 +256,7 @@ describe('DetailHeader', () => {
|
||||
onRenameBase={vi.fn()}
|
||||
onDeleteBase={vi.fn()}
|
||||
onRebuild={vi.fn()}
|
||||
onAddSource={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -264,6 +278,7 @@ describe('DetailHeader', () => {
|
||||
onRenameBase={vi.fn()}
|
||||
onDeleteBase={vi.fn()}
|
||||
onRebuild={vi.fn()}
|
||||
onAddSource={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -284,6 +299,7 @@ describe('DetailHeader', () => {
|
||||
onRenameBase={onRenameBase}
|
||||
onDeleteBase={vi.fn()}
|
||||
onRebuild={vi.fn()}
|
||||
onAddSource={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -307,6 +323,7 @@ describe('DetailHeader', () => {
|
||||
onRenameBase={vi.fn()}
|
||||
onDeleteBase={onDeleteBase}
|
||||
onRebuild={vi.fn()}
|
||||
onAddSource={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -322,4 +339,41 @@ describe('DetailHeader', () => {
|
||||
expect(onDeleteBase).toHaveBeenCalledWith('base-1')
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the updated-at time after the title', () => {
|
||||
render(
|
||||
<DetailHeader
|
||||
base={createKnowledgeBase()}
|
||||
onOpenRagConfig={vi.fn()}
|
||||
onOpenRecallTest={vi.fn()}
|
||||
onRenameBase={vi.fn()}
|
||||
onDeleteBase={vi.fn()}
|
||||
onRebuild={vi.fn()}
|
||||
onAddSource={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('更新于 刚刚')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the add-source menu and forwards the selected source', () => {
|
||||
const onAddSource = vi.fn()
|
||||
|
||||
render(
|
||||
<DetailHeader
|
||||
base={createKnowledgeBase()}
|
||||
onOpenRagConfig={vi.fn()}
|
||||
onOpenRecallTest={vi.fn()}
|
||||
onRenameBase={vi.fn()}
|
||||
onDeleteBase={vi.fn()}
|
||||
onRebuild={vi.fn()}
|
||||
onAddSource={onAddSource}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加数据源' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '文件' }))
|
||||
|
||||
expect(onAddSource).toHaveBeenCalledWith('file')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,7 +31,7 @@ const AddKnowledgeItemDialogFooter = ({
|
||||
? t('knowledge.data_source.add_dialog.footer.selected_notes', { count: selectedNoteCount })
|
||||
: ''
|
||||
return (
|
||||
<div className="flex w-full min-w-0 shrink-0 flex-col gap-3 overflow-hidden">
|
||||
<div className="flex w-full min-w-0 shrink-0 flex-col gap-3">
|
||||
{errorMessage ? (
|
||||
<div
|
||||
role="alert"
|
||||
@@ -52,7 +52,7 @@ const AddKnowledgeItemDialogFooter = ({
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="button"
|
||||
variant="emphasis"
|
||||
variant="default"
|
||||
disabled={!canSubmit || isSubmitting}
|
||||
loading={isSubmitting}
|
||||
onClick={() => void onSubmit()}>
|
||||
|
||||
@@ -93,7 +93,7 @@ const NoteSourceContent = ({ selectedNotes, onToggle }: NoteSourceContentProps)
|
||||
checked={selectedPaths.has(note.externalPath)}
|
||||
onCheckedChange={() => onToggle({ name: note.name, externalPath: note.externalPath })}
|
||||
/>
|
||||
<NotebookPen className="size-3.5 shrink-0 text-foreground-muted" />
|
||||
<NotebookPen className="size-3.5 shrink-0 text-foreground-muted" strokeWidth={1.6} />
|
||||
<span className="min-w-0 truncate text-foreground text-xs leading-4" title={note.name}>
|
||||
{note.name}
|
||||
</span>
|
||||
|
||||
@@ -6,7 +6,7 @@ const BaseNavigatorResizeHandle = ({ onResizeStart }: BaseNavigatorResizeHandleP
|
||||
data-testid="base-navigator-resize-handle"
|
||||
onMouseDown={onResizeStart}
|
||||
className="group/handle absolute inset-y-0 right-0 z-20 w-3 translate-x-1/2 cursor-col-resize">
|
||||
<div className="mx-auto h-full w-px bg-border-hover opacity-0 transition-opacity group-hover/handle:opacity-100" />
|
||||
<div className="mx-auto h-full w-px bg-border-hover opacity-0 transition-opacity group-hover/handle:opacity-60" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ const KnowledgeBaseRow = ({
|
||||
type: 'item',
|
||||
id: 'rename',
|
||||
label: t('knowledge.context.rename'),
|
||||
icon: <PencilLine className="size-3.5" />,
|
||||
icon: <PencilLine className="size-3.5" strokeWidth={1.6} />,
|
||||
onSelect: handleRenameBase
|
||||
}
|
||||
]
|
||||
@@ -59,7 +59,7 @@ const KnowledgeBaseRow = ({
|
||||
type: 'submenu',
|
||||
id: 'move',
|
||||
label: t('knowledge.context.move_to'),
|
||||
icon: <ArrowRightLeft className="size-3.5" />,
|
||||
icon: <ArrowRightLeft className="size-3.5" strokeWidth={1.6} />,
|
||||
children: [
|
||||
...(canMoveToUngrouped
|
||||
? ([
|
||||
@@ -86,7 +86,7 @@ const KnowledgeBaseRow = ({
|
||||
type: 'item',
|
||||
id: 'delete',
|
||||
label: t('knowledge.context.delete'),
|
||||
icon: <Trash2 className="size-3.5" />,
|
||||
icon: <Trash2 className="size-3.5" strokeWidth={1.6} />,
|
||||
destructive: true,
|
||||
onSelect: handleRequestDelete
|
||||
})
|
||||
@@ -126,7 +126,7 @@ const KnowledgeBaseRow = ({
|
||||
size="icon-sm"
|
||||
aria-label={t('common.more')}
|
||||
className={cn(
|
||||
'text-foreground-muted hover:bg-accent group-focus-within/kb:opacity-100 group-focus-within:opacity-100 group-hover/kb:opacity-100 group-hover:opacity-100',
|
||||
'text-foreground/80 hover:bg-accent group-focus-within/kb:opacity-100 group-focus-within:opacity-100 group-hover/kb:opacity-100 group-hover:opacity-100 [&_svg]:[stroke-width:1.6]',
|
||||
moreMenuOpen ? 'opacity-100' : 'opacity-0'
|
||||
)}>
|
||||
<MoreHorizontal />
|
||||
|
||||
@@ -87,7 +87,7 @@ const KnowledgeGroupRow = ({
|
||||
size="icon-sm"
|
||||
aria-label={t('common.more')}
|
||||
className={cn(
|
||||
'size-6 min-h-6 min-w-6 rounded-md p-0 text-foreground-muted hover:bg-accent hover:text-foreground group-focus-within/grp:opacity-100 group-hover/grp:opacity-100 [&_svg]:size-3.5',
|
||||
'size-6 min-h-6 min-w-6 rounded-md p-0 text-foreground/80 hover:bg-accent hover:text-foreground group-focus-within/grp:opacity-100 group-hover/grp:opacity-100 [&_svg]:size-3.5 [&_svg]:[stroke-width:1.6]',
|
||||
moreMenuOpen ? 'opacity-100' : 'opacity-0'
|
||||
)}>
|
||||
<MoreHorizontal />
|
||||
|
||||
@@ -20,7 +20,6 @@ export interface DataSourcePanelProps {
|
||||
hasMore?: boolean
|
||||
isLoadingMore?: boolean
|
||||
onLoadMore?: () => void
|
||||
updatedAt: string
|
||||
onAdd: (source?: KnowledgeItemType, files?: File[]) => void
|
||||
onItemClick?: (itemId: string) => void
|
||||
onDelete: (item: KnowledgeItem) => void | Promise<unknown>
|
||||
@@ -47,9 +46,9 @@ const DataSourceEmptyState = ({ onAddSource }: { onAddSource: (source: Knowledge
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="h-9 w-24 rounded-lg px-3 font-medium"
|
||||
className="h-8 w-20 rounded-lg px-3 font-medium"
|
||||
onClick={() => onAddSource(source.value)}>
|
||||
<Icon className="size-4 text-foreground-secondary" />
|
||||
<Icon className="size-4" strokeWidth={1.6} />
|
||||
{t(source.labelKey)}
|
||||
</Button>
|
||||
)
|
||||
@@ -67,7 +66,6 @@ const DataSourcePanel = ({
|
||||
hasMore = false,
|
||||
isLoadingMore = false,
|
||||
onLoadMore = () => undefined,
|
||||
updatedAt,
|
||||
onAdd,
|
||||
onItemClick,
|
||||
onDelete,
|
||||
@@ -153,17 +151,17 @@ const DataSourcePanel = ({
|
||||
<KnowledgePanelShell
|
||||
headerClassName="shrink-0 px-3 pt-1"
|
||||
header={
|
||||
<div className="border-border-muted border-b pb-3">
|
||||
<DataSourcePanelHeader
|
||||
total={total}
|
||||
loadedCount={items.length}
|
||||
selectedCount={selectedIds.size}
|
||||
updatedAt={updatedAt}
|
||||
onBulkReindex={handleBulkReindex}
|
||||
onBulkDelete={() => setIsBulkDeleteOpen(true)}
|
||||
onAdd={handleAddSource}
|
||||
/>
|
||||
</div>
|
||||
selectedIds.size > 0 ? (
|
||||
<div className="border-border-muted border-b pb-3">
|
||||
<DataSourcePanelHeader
|
||||
total={total}
|
||||
loadedCount={items.length}
|
||||
selectedCount={selectedIds.size}
|
||||
onBulkReindex={handleBulkReindex}
|
||||
onBulkDelete={() => setIsBulkDeleteOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
) : undefined
|
||||
}>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{!isLoading && items.length === 0 ? (
|
||||
|
||||
@@ -1,114 +1,49 @@
|
||||
import { Button, MenuItem, MenuList, Popover, PopoverContent, PopoverTrigger } from '@cherrystudio/ui'
|
||||
import { formatRelativeTime } from '@renderer/utils/time'
|
||||
import type { KnowledgeItemType } from '@shared/data/types/knowledge'
|
||||
import { Plus, RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Button } from '@cherrystudio/ui'
|
||||
import { RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { KNOWLEDGE_DATA_SOURCE_TYPES } from '../../components/addKnowledgeItemDialog/constants'
|
||||
|
||||
interface DataSourcePanelHeaderProps {
|
||||
/** Server-side total across all pages. */
|
||||
total: number
|
||||
/** Rows currently loaded in the renderer (≤ total when pages remain). */
|
||||
loadedCount: number
|
||||
selectedCount: number
|
||||
updatedAt: string
|
||||
onBulkReindex: () => void
|
||||
onBulkDelete: () => void
|
||||
onAdd: (source: KnowledgeItemType) => void
|
||||
}
|
||||
|
||||
const DataSourcePanelHeader = ({
|
||||
total,
|
||||
loadedCount,
|
||||
selectedCount,
|
||||
updatedAt,
|
||||
onBulkReindex,
|
||||
onBulkDelete,
|
||||
onAdd
|
||||
onBulkDelete
|
||||
}: DataSourcePanelHeaderProps) => {
|
||||
const { t, i18n } = useTranslation()
|
||||
const [isSourceMenuOpen, setIsSourceMenuOpen] = useState(false)
|
||||
|
||||
const handleSourceSelect = useCallback(
|
||||
(source: KnowledgeItemType) => {
|
||||
setIsSourceMenuOpen(false)
|
||||
onAdd(source)
|
||||
},
|
||||
[onAdd]
|
||||
)
|
||||
|
||||
if (selectedCount > 0) {
|
||||
return (
|
||||
<div className="flex min-h-8 min-w-0 items-center justify-between gap-3">
|
||||
<span className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="truncate text-foreground text-sm">
|
||||
{t('knowledge.data_source.bulk.selected_count', { count: selectedCount })}
|
||||
</span>
|
||||
{/* Selection only covers loaded rows; warn when unloaded pages remain so the
|
||||
checked-all state doesn't read as "all rows in the base". */}
|
||||
{total > loadedCount ? (
|
||||
<span className="shrink-0 text-foreground-muted text-xs">
|
||||
{t('knowledge.data_source.bulk.loaded_only_hint', { total })}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={onBulkReindex}>
|
||||
<RefreshCw className="size-3.5" />
|
||||
{t('knowledge.data_source.bulk.reindex')}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onBulkDelete}>
|
||||
<Trash2 className="size-3.5" />
|
||||
{t('knowledge.data_source.bulk.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-8 min-w-0 items-center justify-between gap-2">
|
||||
<span className="min-w-0 truncate text-foreground-muted text-xs leading-4">
|
||||
{t('knowledge.meta.updated_at', { time: formatRelativeTime(updatedAt, i18n.language) })}
|
||||
<div className="flex min-h-8 min-w-0 items-center justify-between gap-3">
|
||||
<span className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="truncate text-foreground text-sm">
|
||||
{t('knowledge.data_source.bulk.selected_count', { count: selectedCount })}
|
||||
</span>
|
||||
{/* Selection only covers loaded rows; warn when unloaded pages remain so the
|
||||
checked-all state doesn't read as "all rows in the base". */}
|
||||
{total > loadedCount ? (
|
||||
<span className="shrink-0 text-foreground-muted text-xs">
|
||||
{t('knowledge.data_source.bulk.loaded_only_hint', { total })}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Popover open={isSourceMenuOpen} onOpenChange={setIsSourceMenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isSourceMenuOpen}
|
||||
className="min-h-0 rounded-lg px-3 py-1.5 font-medium text-foreground-secondary text-sm leading-5 shadow-none hover:bg-accent hover:text-foreground">
|
||||
<Plus className="size-3.5" />
|
||||
{t('knowledge.data_source.toolbar.add')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
collisionPadding={8}
|
||||
className="w-[var(--radix-popover-trigger-width)] rounded-xl p-1.5"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}>
|
||||
<MenuList role="menu" className="gap-1">
|
||||
{KNOWLEDGE_DATA_SOURCE_TYPES.map((source) => (
|
||||
<MenuItem
|
||||
key={source.value}
|
||||
role="menuitem"
|
||||
variant="ghost"
|
||||
label={t(source.labelKey)}
|
||||
className="h-8 rounded-lg px-2.5 text-sm"
|
||||
onClick={() => handleSourceSelect(source.value)}
|
||||
/>
|
||||
))}
|
||||
</MenuList>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onBulkReindex}>
|
||||
<RefreshCw className="size-3.5" />
|
||||
{t('knowledge.data_source.bulk.reindex')}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onBulkDelete}>
|
||||
<Trash2 className="size-3.5" />
|
||||
{t('knowledge.data_source.bulk.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -27,7 +27,7 @@ const KnowledgeItemChunkCard = ({ chunk }: { chunk: KnowledgeItemChunk }) => {
|
||||
return (
|
||||
<div className="rounded-lg border border-border-subtle transition-all hover:border-border-hover">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<span className="flex size-5 shrink-0 items-center justify-center rounded bg-accent text-foreground-muted text-xs leading-4">
|
||||
<span className="flex size-5 shrink-0 items-center justify-center rounded bg-accent text-foreground-secondary text-xs leading-4">
|
||||
{chunk.metadata.chunkIndex + 1}
|
||||
</span>
|
||||
<span className="flex-1 text-foreground-muted text-xs leading-4">
|
||||
@@ -35,7 +35,7 @@ const KnowledgeItemChunkCard = ({ chunk }: { chunk: KnowledgeItemChunk }) => {
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-3 pb-3">
|
||||
<p className="line-clamp-2 text-foreground-secondary text-sm leading-relaxed">{chunk.content}</p>
|
||||
<p className="line-clamp-2 text-foreground text-sm leading-relaxed">{chunk.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -105,7 +105,7 @@ const KnowledgeItemRow = ({
|
||||
type: 'item',
|
||||
id: 'preview-source',
|
||||
label: t('knowledge.data_source.actions.preview_source'),
|
||||
icon: <BookOpen className="size-3.5" />,
|
||||
icon: <BookOpen className="size-3.5 text-current" strokeWidth={1.6} />,
|
||||
onSelect: () => {
|
||||
void Promise.resolve(onPreviewSource()).catch((error) => {
|
||||
window.toast.error(formatErrorMessageWithPrefix(error, t('knowledge.data_source.preview.failed')))
|
||||
@@ -119,7 +119,7 @@ const KnowledgeItemRow = ({
|
||||
type: 'item',
|
||||
id: 'view-chunks',
|
||||
label: t('knowledge.data_source.actions.view_chunks'),
|
||||
icon: <Eye className="size-3.5" />,
|
||||
icon: <Eye className="size-3.5 text-current" strokeWidth={1.6} />,
|
||||
onSelect: onViewChunks
|
||||
})
|
||||
}
|
||||
@@ -129,7 +129,7 @@ const KnowledgeItemRow = ({
|
||||
type: 'item',
|
||||
id: 'reindex',
|
||||
label: t('knowledge.data_source.actions.reindex'),
|
||||
icon: <RefreshCw className="size-3.5" />,
|
||||
icon: <RefreshCw className="size-3.5 text-current" strokeWidth={1.6} />,
|
||||
onSelect: () => {
|
||||
void Promise.resolve(onReindex()).catch((error) => {
|
||||
window.toast.error(formatErrorMessageWithPrefix(error, t('knowledge.data_source.reindex_failed')))
|
||||
@@ -143,7 +143,7 @@ const KnowledgeItemRow = ({
|
||||
type: 'item',
|
||||
id: 'delete',
|
||||
label: t('knowledge.data_source.actions.delete'),
|
||||
icon: <Trash2 className="size-3.5" />,
|
||||
icon: <Trash2 className="size-3.5 text-current" strokeWidth={1.6} />,
|
||||
destructive: true,
|
||||
onSelect: () => {
|
||||
void Promise.resolve(onDelete()).catch((error) => {
|
||||
|
||||
@@ -213,10 +213,6 @@ vi.mock('react-i18next', () => ({
|
||||
return `确认删除选中的 ${options?.count} 个数据源`
|
||||
}
|
||||
|
||||
if (key === 'knowledge.meta.updated_at') {
|
||||
return `更新于 ${options?.time ?? ''}`
|
||||
}
|
||||
|
||||
return (
|
||||
(
|
||||
{
|
||||
@@ -296,28 +292,12 @@ describe('DataSourcePanel', () => {
|
||||
|
||||
it('renders loading and empty states through the list composition without changing panel behavior', () => {
|
||||
const { rerender } = render(
|
||||
<DataSourcePanel
|
||||
updatedAt="2026-04-15T09:00:00+08:00"
|
||||
items={[]}
|
||||
isLoading
|
||||
onAdd={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onReindex={vi.fn()}
|
||||
/>
|
||||
<DataSourcePanel items={[]} isLoading onAdd={vi.fn()} onDelete={vi.fn()} onReindex={vi.fn()} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('加载中...')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<DataSourcePanel
|
||||
updatedAt="2026-04-15T09:00:00+08:00"
|
||||
items={[]}
|
||||
isLoading={false}
|
||||
onAdd={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onReindex={vi.fn()}
|
||||
/>
|
||||
)
|
||||
rerender(<DataSourcePanel items={[]} isLoading={false} onAdd={vi.fn()} onDelete={vi.fn()} onReindex={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('上传第一个数据源')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '文件' })).toBeInTheDocument()
|
||||
@@ -331,14 +311,7 @@ describe('DataSourcePanel', () => {
|
||||
const onAdd = vi.fn()
|
||||
|
||||
const { rerender } = render(
|
||||
<DataSourcePanel
|
||||
updatedAt="2026-04-15T09:00:00+08:00"
|
||||
items={[]}
|
||||
isLoading={false}
|
||||
onAdd={onAdd}
|
||||
onDelete={vi.fn()}
|
||||
onReindex={vi.fn()}
|
||||
/>
|
||||
<DataSourcePanel items={[]} isLoading={false} onAdd={onAdd} onDelete={vi.fn()} onReindex={vi.fn()} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('暂无数据源')).toBeInTheDocument()
|
||||
@@ -355,16 +328,7 @@ describe('DataSourcePanel', () => {
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith('file')
|
||||
|
||||
rerender(
|
||||
<DataSourcePanel
|
||||
updatedAt="2026-04-15T09:00:00+08:00"
|
||||
items={[]}
|
||||
isLoading={false}
|
||||
onAdd={onAdd}
|
||||
onDelete={vi.fn()}
|
||||
onReindex={vi.fn()}
|
||||
/>
|
||||
)
|
||||
rerender(<DataSourcePanel items={[]} isLoading={false} onAdd={onAdd} onDelete={vi.fn()} onReindex={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '链接' }))
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith('url')
|
||||
@@ -373,7 +337,6 @@ describe('DataSourcePanel', () => {
|
||||
it('uses the first non-empty note line as the title and leaves blank notes without the old fallback label', () => {
|
||||
render(
|
||||
<DataSourcePanel
|
||||
updatedAt="2026-04-15T09:00:00+08:00"
|
||||
items={[
|
||||
createNoteItem({ id: 'note-1', content: '\n \n 第一行标题 \n第二行内容' }),
|
||||
createNoteItem({ id: 'note-2', content: '\n \n' })
|
||||
@@ -392,7 +355,6 @@ describe('DataSourcePanel', () => {
|
||||
it('renders url and directory items from their required source fields', () => {
|
||||
render(
|
||||
<DataSourcePanel
|
||||
updatedAt="2026-04-15T09:00:00+08:00"
|
||||
items={[
|
||||
createUrlItem({ id: 'url-1', source: 'https://example.com/product-docs' }),
|
||||
createDirectoryItem({ id: 'directory-1', source: '/Users/eeee/本地资料夹' })
|
||||
@@ -408,13 +370,11 @@ describe('DataSourcePanel', () => {
|
||||
const directoryTitle = screen.getByText('本地资料夹')
|
||||
expect(directoryTitle).toBeInTheDocument()
|
||||
expect(directoryTitle).toHaveAttribute('title', '/Users/eeee/本地资料夹')
|
||||
expect(screen.getByText('更新于 刚刚')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders processing directory rows as processing when no phase is available', () => {
|
||||
render(
|
||||
<DataSourcePanel
|
||||
updatedAt="2026-04-15T09:00:00+08:00"
|
||||
items={[createDirectoryItem({ id: 'directory-1', source: '/Users/eeee/本地资料夹', status: 'processing' })]}
|
||||
isLoading={false}
|
||||
onAdd={vi.fn()}
|
||||
@@ -436,7 +396,6 @@ describe('DataSourcePanel', () => {
|
||||
// migration-failed tooltip so the user knows to delete and re-upload.
|
||||
render(
|
||||
<DataSourcePanel
|
||||
updatedAt="2026-04-15T09:00:00+08:00"
|
||||
items={[
|
||||
createDirectoryItem({
|
||||
id: 'directory-1',
|
||||
@@ -456,74 +415,11 @@ describe('DataSourcePanel', () => {
|
||||
expect(screen.getByLabelText('该文件夹内容迁移失败,请删除后重新上传。')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not open the add source dialog from the header button before a source is selected', () => {
|
||||
const onAdd = vi.fn()
|
||||
|
||||
render(
|
||||
<DataSourcePanel
|
||||
updatedAt="2026-04-15T09:00:00+08:00"
|
||||
items={[createFileItem({ id: 'file-1', originName: '季度报告.pdf' })]}
|
||||
isLoading={false}
|
||||
onAdd={onAdd}
|
||||
onDelete={vi.fn()}
|
||||
onReindex={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加数据源' }))
|
||||
|
||||
expect(onAdd).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('季度报告.pdf')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the add dialog when selecting the file source from the header menu', () => {
|
||||
const onAdd = vi.fn()
|
||||
|
||||
render(
|
||||
<DataSourcePanel
|
||||
updatedAt="2026-04-15T09:00:00+08:00"
|
||||
items={[createFileItem({ id: 'file-1', originName: '季度报告.pdf' })]}
|
||||
isLoading={false}
|
||||
onAdd={onAdd}
|
||||
onDelete={vi.fn()}
|
||||
onReindex={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(document.querySelector('input[type="file"]')).toBeNull()
|
||||
|
||||
fireEvent.mouseEnter(screen.getByRole('button', { name: '添加数据源' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '文件' }))
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith('file')
|
||||
})
|
||||
|
||||
it('shows source choices on header add hover and forwards the selected source', () => {
|
||||
const onAdd = vi.fn()
|
||||
|
||||
render(
|
||||
<DataSourcePanel
|
||||
updatedAt="2026-04-15T09:00:00+08:00"
|
||||
items={[createFileItem({ id: 'file-1', originName: '季度报告.pdf' })]}
|
||||
isLoading={false}
|
||||
onAdd={onAdd}
|
||||
onDelete={vi.fn()}
|
||||
onReindex={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.mouseEnter(screen.getByRole('button', { name: '添加数据源' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '目录' }))
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith('directory')
|
||||
})
|
||||
|
||||
it('prunes selected item ids when items are removed', async () => {
|
||||
const onDelete = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const { rerender } = render(
|
||||
<DataSourcePanel
|
||||
updatedAt="2026-04-15T09:00:00+08:00"
|
||||
items={[
|
||||
createFileItem({ id: 'file-1', originName: '季度报告.pdf' }),
|
||||
createFileItem({ id: 'file-2', originName: '会议记录.pdf' })
|
||||
@@ -540,7 +436,6 @@ describe('DataSourcePanel', () => {
|
||||
|
||||
rerender(
|
||||
<DataSourcePanel
|
||||
updatedAt="2026-04-15T09:00:00+08:00"
|
||||
items={[createFileItem({ id: 'file-1', originName: '季度报告.pdf' })]}
|
||||
isLoading={false}
|
||||
onAdd={vi.fn()}
|
||||
@@ -570,7 +465,6 @@ describe('DataSourcePanel', () => {
|
||||
|
||||
render(
|
||||
<DataSourcePanel
|
||||
updatedAt="2026-04-15T09:00:00+08:00"
|
||||
items={[item]}
|
||||
isLoading={false}
|
||||
onAdd={vi.fn()}
|
||||
@@ -591,7 +485,6 @@ describe('DataSourcePanel', () => {
|
||||
|
||||
render(
|
||||
<DataSourcePanel
|
||||
updatedAt="2026-04-15T09:00:00+08:00"
|
||||
items={[item]}
|
||||
isLoading={false}
|
||||
onAdd={vi.fn()}
|
||||
@@ -612,7 +505,6 @@ describe('DataSourcePanel', () => {
|
||||
|
||||
render(
|
||||
<DataSourcePanel
|
||||
updatedAt="2026-04-15T09:00:00+08:00"
|
||||
items={[createFileItem({ id: 'file-1', originName: '季度报告.pdf' })]}
|
||||
isLoading={false}
|
||||
onAdd={vi.fn()}
|
||||
@@ -639,7 +531,6 @@ describe('DataSourcePanel', () => {
|
||||
|
||||
render(
|
||||
<DataSourcePanel
|
||||
updatedAt="2026-04-15T09:00:00+08:00"
|
||||
items={[createFileItem({ id: 'file-1', originName: '季度报告.pdf' })]}
|
||||
isLoading={false}
|
||||
onAdd={vi.fn()}
|
||||
@@ -663,7 +554,6 @@ describe('DataSourcePanel', () => {
|
||||
|
||||
render(
|
||||
<DataSourcePanel
|
||||
updatedAt="2026-04-15T09:00:00+08:00"
|
||||
items={[createFileItem({ id: 'file-1', originName: '季度报告.pdf' })]}
|
||||
isLoading={false}
|
||||
onAdd={vi.fn()}
|
||||
@@ -685,7 +575,6 @@ describe('DataSourcePanel', () => {
|
||||
|
||||
render(
|
||||
<DataSourcePanel
|
||||
updatedAt="2026-04-15T09:00:00+08:00"
|
||||
items={[
|
||||
createFileItem({ id: 'file-1', originName: '季度报告.pdf' }),
|
||||
createFileItem({ id: 'file-2', originName: '会议记录.pdf' })
|
||||
@@ -711,7 +600,6 @@ describe('DataSourcePanel', () => {
|
||||
|
||||
render(
|
||||
<DataSourcePanel
|
||||
updatedAt="2026-04-15T09:00:00+08:00"
|
||||
items={[
|
||||
createFileItem({ id: 'file-1', originName: '季度报告.pdf' }),
|
||||
createFileItem({ id: 'file-2', originName: '会议记录.pdf' })
|
||||
@@ -739,7 +627,6 @@ describe('DataSourcePanel', () => {
|
||||
|
||||
render(
|
||||
<DataSourcePanel
|
||||
updatedAt="2026-04-15T09:00:00+08:00"
|
||||
items={[
|
||||
createFileItem({ id: 'file-1', originName: '季度报告.pdf' }),
|
||||
createFileItem({ id: 'file-2', originName: '会议记录.pdf' })
|
||||
@@ -783,7 +670,6 @@ describe('DataSourcePanel', () => {
|
||||
|
||||
render(
|
||||
<DataSourcePanel
|
||||
updatedAt="2026-04-15T09:00:00+08:00"
|
||||
items={[
|
||||
createFileItem({ id: 'file-1', originName: '季度报告.pdf' }),
|
||||
createFileItem({ id: 'file-2', originName: '会议记录.pdf' })
|
||||
@@ -811,7 +697,6 @@ describe('DataSourcePanel', () => {
|
||||
it('selects all rows from the header checkbox and clears selection when toggled again from all selected', async () => {
|
||||
render(
|
||||
<DataSourcePanel
|
||||
updatedAt="2026-04-15T09:00:00+08:00"
|
||||
items={[
|
||||
createFileItem({ id: 'file-1', originName: '季度报告.pdf' }),
|
||||
createFileItem({ id: 'file-2', originName: '会议记录.pdf' })
|
||||
@@ -843,7 +728,6 @@ describe('DataSourcePanel', () => {
|
||||
it('warns that select-all only covers loaded rows when more pages remain on the server', () => {
|
||||
render(
|
||||
<DataSourcePanel
|
||||
updatedAt="2026-04-15T09:00:00+08:00"
|
||||
items={[
|
||||
createFileItem({ id: 'file-1', originName: '季度报告.pdf' }),
|
||||
createFileItem({ id: 'file-2', originName: '会议记录.pdf' })
|
||||
@@ -867,7 +751,6 @@ describe('DataSourcePanel', () => {
|
||||
it('shows the header select-all checkbox as partially selected after deselecting one selected row', () => {
|
||||
render(
|
||||
<DataSourcePanel
|
||||
updatedAt="2026-04-15T09:00:00+08:00"
|
||||
items={[
|
||||
createFileItem({ id: 'file-1', originName: '季度报告.pdf' }),
|
||||
createFileItem({ id: 'file-2', originName: '会议记录.pdf' })
|
||||
@@ -889,7 +772,6 @@ describe('DataSourcePanel', () => {
|
||||
it('prunes selected item ids when the backing item list changes', async () => {
|
||||
const { rerender } = render(
|
||||
<DataSourcePanel
|
||||
updatedAt="2026-04-15T09:00:00+08:00"
|
||||
items={[
|
||||
createFileItem({ id: 'file-1', originName: '季度报告.pdf' }),
|
||||
createFileItem({ id: 'file-2', originName: '会议记录.pdf' })
|
||||
@@ -906,7 +788,6 @@ describe('DataSourcePanel', () => {
|
||||
|
||||
rerender(
|
||||
<DataSourcePanel
|
||||
updatedAt="2026-04-15T09:00:00+08:00"
|
||||
items={[createFileItem({ id: 'file-2', originName: '会议记录.pdf' })]}
|
||||
isLoading={false}
|
||||
onAdd={vi.fn()}
|
||||
@@ -926,7 +807,6 @@ describe('DataSourcePanel', () => {
|
||||
|
||||
render(
|
||||
<DataSourcePanel
|
||||
updatedAt="2026-04-15T09:00:00+08:00"
|
||||
items={[createFileItem({ id: 'file-1', originName: '季度报告.pdf' })]}
|
||||
isLoading={false}
|
||||
onAdd={vi.fn()}
|
||||
|
||||
@@ -6,35 +6,24 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import DataSourcePanelHeader from '../DataSourcePanelHeader'
|
||||
|
||||
vi.mock('@renderer/utils/time', () => ({
|
||||
formatRelativeTime: () => '刚刚'
|
||||
}))
|
||||
|
||||
vi.mock('@cherrystudio/ui', () => ({
|
||||
Button: ({ children, ...props }: { children: ReactNode; [key: string]: unknown }) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
MenuItem: ({ label, ...props }: { label: string; [key: string]: unknown }) => <button {...props}>{label}</button>,
|
||||
MenuList: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
Popover: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
|
||||
<button type="button" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
i18n: { language: 'zh-CN' },
|
||||
t: (key: string, opts?: Record<string, unknown>) => {
|
||||
if (key === 'knowledge.data_source.bulk.selected_count') return `已选 ${opts?.count}`
|
||||
if (key === 'knowledge.meta.updated_at') return `更新于 ${opts?.time}`
|
||||
if (key === 'knowledge.data_source.bulk.loaded_only_hint') return `仅已加载,共 ${opts?.total} 项`
|
||||
return (
|
||||
(
|
||||
{
|
||||
'knowledge.data_source.bulk.cancel': '取消',
|
||||
'knowledge.data_source.bulk.reindex': '重新索引',
|
||||
'knowledge.data_source.bulk.delete': '删除',
|
||||
'knowledge.data_source.toolbar.add': '添加'
|
||||
'knowledge.data_source.bulk.delete': '删除'
|
||||
} as Record<string, string>
|
||||
)[key] ?? key
|
||||
)
|
||||
@@ -45,66 +34,36 @@ vi.mock('react-i18next', () => ({
|
||||
const baseProps = {
|
||||
total: 5,
|
||||
loadedCount: 5,
|
||||
selectedCount: 0,
|
||||
updatedAt: '2026-06-16T00:00:00.000Z',
|
||||
selectedCount: 2,
|
||||
onBulkReindex: vi.fn(),
|
||||
onBulkDelete: vi.fn(),
|
||||
onAdd: vi.fn()
|
||||
onBulkDelete: vi.fn()
|
||||
}
|
||||
|
||||
describe('DataSourcePanelHeader', () => {
|
||||
it('renders the updated time and add button in the default state', () => {
|
||||
render(<DataSourcePanelHeader {...baseProps} selectedCount={0} />)
|
||||
|
||||
expect(screen.getByText('更新于 刚刚')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '添加' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches to the bulk toolbar when rows are selected', () => {
|
||||
render(<DataSourcePanelHeader {...baseProps} selectedCount={2} />)
|
||||
it('renders the bulk toolbar with the selected count', () => {
|
||||
render(<DataSourcePanelHeader {...baseProps} />)
|
||||
|
||||
expect(screen.getByText('已选 2')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '取消' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '重新索引' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '删除' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('warns that a selection only covers loaded rows when unloaded pages remain', () => {
|
||||
const { rerender } = render(
|
||||
<DataSourcePanelHeader {...baseProps} total={200} loadedCount={50} selectedCount={50} />
|
||||
)
|
||||
const { rerender } = render(<DataSourcePanelHeader {...baseProps} total={200} loadedCount={50} />)
|
||||
|
||||
expect(screen.getByText('仅已加载,共 200 项')).toBeInTheDocument()
|
||||
|
||||
// Fully loaded (total === loadedCount): no hint.
|
||||
rerender(<DataSourcePanelHeader {...baseProps} total={50} loadedCount={50} selectedCount={50} />)
|
||||
rerender(<DataSourcePanelHeader {...baseProps} total={50} loadedCount={50} />)
|
||||
|
||||
expect(screen.queryByText('仅已加载,共 50 项')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Regression for the QA issue "选中文件后列表轻微上移": the default toolbar
|
||||
// (32px add button) and the bulk toolbar (28px sm buttons) differed by 4px,
|
||||
// shifting the list on selection. Both states must keep the same min height.
|
||||
it('keeps the same min height across default and selected states', () => {
|
||||
const { container: defaultContainer } = render(<DataSourcePanelHeader {...baseProps} selectedCount={0} />)
|
||||
const { container: selectedContainer } = render(<DataSourcePanelHeader {...baseProps} selectedCount={2} />)
|
||||
|
||||
expect(defaultContainer.firstChild).toHaveClass('min-h-8')
|
||||
expect(selectedContainer.firstChild).toHaveClass('min-h-8')
|
||||
})
|
||||
|
||||
it('invokes bulk callbacks from the selected-state toolbar', () => {
|
||||
it('invokes bulk callbacks from the toolbar', () => {
|
||||
const onBulkReindex = vi.fn()
|
||||
const onBulkDelete = vi.fn()
|
||||
|
||||
render(
|
||||
<DataSourcePanelHeader
|
||||
{...baseProps}
|
||||
selectedCount={1}
|
||||
onBulkReindex={onBulkReindex}
|
||||
onBulkDelete={onBulkDelete}
|
||||
/>
|
||||
)
|
||||
render(<DataSourcePanelHeader {...baseProps} onBulkReindex={onBulkReindex} onBulkDelete={onBulkDelete} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新索引' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除' }))
|
||||
|
||||
@@ -163,7 +163,7 @@ const ActiveRagConfigPanel = ({ base, onRestoreBase }: RagConfigPanelProps) => {
|
||||
<RotateCcw />
|
||||
{t('knowledge.rag.reset_action')}
|
||||
</Button>
|
||||
<Button type="button" variant="emphasis" loading={isLoading} disabled={!canSubmit} onClick={handleSave}>
|
||||
<Button type="button" variant="default" loading={isLoading} disabled={!canSubmit} onClick={handleSave}>
|
||||
{embeddingModelChanged ? t('knowledge.restore.submit') : t('knowledge.rag.save_action')}
|
||||
</Button>
|
||||
</KnowledgeDialogFooter>
|
||||
|
||||
@@ -377,11 +377,11 @@ describe('RagConfigPanel', () => {
|
||||
it('uses the mini-apps style flat field layout', () => {
|
||||
renderRagConfigPanel()
|
||||
|
||||
// Each field label is now a strong text-sm font-medium label (mini-apps FieldLabel parity).
|
||||
expect(screen.getByText('文档处理')).toHaveClass('font-medium', 'text-sm')
|
||||
expect(screen.getByText('分块大小')).toHaveClass('font-medium', 'text-sm')
|
||||
expect(screen.getByText('嵌入模型')).toHaveClass('font-medium', 'text-sm')
|
||||
expect(screen.getByText('请求文档片段数 (Top K)')).toHaveClass('font-medium', 'text-sm')
|
||||
// Each field label is a small, regular-weight label (mini-apps FieldLabel parity).
|
||||
expect(screen.getByText('文档处理')).toHaveClass('font-normal', 'text-xs')
|
||||
expect(screen.getByText('分块大小')).toHaveClass('font-normal', 'text-xs')
|
||||
expect(screen.getByText('嵌入模型')).toHaveClass('font-normal', 'text-xs')
|
||||
expect(screen.getByText('请求文档片段数 (Top K)')).toHaveClass('font-normal', 'text-xs')
|
||||
// Section-level small-caps headings are gone — no Chunking / Embedding / Retrieval section title in the DOM.
|
||||
expect(screen.queryByText('Chunking')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Embedding')).not.toBeInTheDocument()
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { ReactNode } from 'react'
|
||||
export const RagFieldLabel = ({ className, label, hint }: { className?: string; label: string; hint?: string }) => {
|
||||
return (
|
||||
<div className={cn('mb-2 flex items-center gap-1.5', className)}>
|
||||
<span className="font-medium text-foreground text-sm">{label}</span>
|
||||
<span className="font-normal text-foreground text-xs">{label}</span>
|
||||
{hint ? (
|
||||
<Tooltip content={hint} placement="top" className="w-fit max-w-sm px-2.5 py-1.5 text-[10px] leading-relaxed">
|
||||
<Info size={12} className="cursor-help text-muted-foreground" />
|
||||
@@ -163,7 +163,7 @@ export const RagSliderField = ({
|
||||
step={step}
|
||||
size="md"
|
||||
disabled={disabled}
|
||||
className="w-full **:data-[slot=slider-thumb]:border-primary **:data-[slot=slider-range]:bg-primary **:data-[slot=slider-thumb]:bg-background **:data-[slot=slider-track]:bg-muted **:data-[slot=slider-thumb]:shadow-sm"
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
<div className="mt-px flex items-center justify-between text-foreground-muted text-xs leading-4">
|
||||
|
||||
@@ -18,7 +18,7 @@ const RecallHistoryList = () => {
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-auto min-h-0 rounded-none p-0 text-foreground-muted text-xs leading-4 shadow-none transition-colors hover:bg-transparent hover:text-red-500"
|
||||
className="h-auto min-h-0 rounded-none p-0 text-foreground-muted text-xs leading-4 shadow-none transition-colors hover:bg-transparent hover:text-destructive"
|
||||
onClick={clearHistory}>
|
||||
{t('knowledge.recall.history_clear')}
|
||||
</Button>
|
||||
|
||||
@@ -43,7 +43,7 @@ const RecallResultCard = ({ item, index }: RecallResultCardProps) => {
|
||||
return (
|
||||
<div className="group/chunk rounded-md border border-border-subtle bg-background transition-all hover:border-border-hover">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<span className="flex size-5 shrink-0 items-center justify-center rounded bg-background-subtle text-foreground-muted text-xs leading-4">
|
||||
<span className="flex size-5 shrink-0 items-center justify-center rounded bg-background-subtle text-foreground-secondary text-xs leading-4">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
@@ -60,7 +60,7 @@ const RecallResultCard = ({ item, index }: RecallResultCardProps) => {
|
||||
aria-label={t('knowledge.recall.copy')}
|
||||
className={`size-5 min-h-5 shrink-0 rounded p-0 shadow-none transition-all hover:bg-accent hover:text-foreground group-hover/chunk:opacity-100 ${copied ? 'text-success opacity-100' : 'text-foreground-muted opacity-0'}`}
|
||||
onClick={() => void copyContent()}>
|
||||
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
{copied ? <Check className="size-3" strokeWidth={1.6} /> : <Copy className="size-3" strokeWidth={1.6} />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -73,7 +73,7 @@ const RecallResultCard = ({ item, index }: RecallResultCardProps) => {
|
||||
</div>
|
||||
<div className="min-w-0 overflow-hidden px-3 pb-3">
|
||||
<p
|
||||
className={`wrap-anywhere min-w-0 whitespace-normal text-foreground-secondary text-sm leading-relaxed ${isExpanded ? '' : 'line-clamp-2'}`}>
|
||||
className={`wrap-anywhere min-w-0 whitespace-normal text-foreground text-sm leading-relaxed ${isExpanded ? '' : 'line-clamp-2'}`}>
|
||||
{item.content}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -52,6 +52,7 @@ const KnowledgePageDetailSection = () => {
|
||||
onRenameBase={openRenameBaseDialog}
|
||||
onDeleteBase={deleteBase}
|
||||
onRebuild={() => openRestoreBaseDialog(selectedBase)}
|
||||
onAddSource={openAddSourceDialog}
|
||||
/>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden bg-background">
|
||||
@@ -65,7 +66,6 @@ const KnowledgePageDetailSection = () => {
|
||||
hasMore={hasMoreItems}
|
||||
isLoadingMore={isLoadingMoreItems}
|
||||
onLoadMore={loadMoreItems}
|
||||
updatedAt={selectedBase.updatedAt}
|
||||
onAdd={openAddSourceDialog}
|
||||
onItemClick={openItemChunks}
|
||||
onDelete={deleteItem}
|
||||
|
||||
@@ -26,7 +26,7 @@ export function AssistantCatalogTabRail({ tabs, activeTab, onTabChange }: Assist
|
||||
size="icon-sm"
|
||||
aria-label={t('library.assistant_catalog.scroll_left')}
|
||||
onClick={() => scrollRail(-1)}
|
||||
className="shrink-0 rounded-full text-foreground-muted hover:text-foreground">
|
||||
className="shrink-0 rounded-full text-foreground/80 hover:text-foreground [&_svg]:[stroke-width:1.6]">
|
||||
<ChevronLeft size={14} />
|
||||
</Button>
|
||||
<div className="relative min-w-0 flex-1">
|
||||
@@ -55,7 +55,7 @@ export function AssistantCatalogTabRail({ tabs, activeTab, onTabChange }: Assist
|
||||
size="icon-sm"
|
||||
aria-label={t('library.assistant_catalog.scroll_right')}
|
||||
onClick={() => scrollRail(1)}
|
||||
className="shrink-0 rounded-full text-foreground-muted hover:text-foreground">
|
||||
className="shrink-0 rounded-full text-foreground/80 hover:text-foreground [&_svg]:[stroke-width:1.6]">
|
||||
<ChevronRight size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ interface Props {
|
||||
|
||||
const ITEM_CLASS =
|
||||
'h-8 w-full cursor-pointer gap-1.5 rounded-lg border-0 px-1.5 text-[13px] font-normal ' +
|
||||
'text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-foreground [&_svg]:size-4 ' +
|
||||
'text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-foreground [&_svg]:size-4 [&_svg]:text-current ' +
|
||||
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-foreground ' +
|
||||
'focus-visible:bg-sidebar-accent focus-visible:text-sidebar-foreground focus-visible:ring-1 focus-visible:ring-sidebar-ring'
|
||||
|
||||
|
||||
@@ -268,7 +268,7 @@ export function ResourceCard({ resource: r, allTagNames, onDelete, onDuplicate,
|
||||
size="icon-sm"
|
||||
aria-label={t('common.more')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-foreground-muted opacity-0 hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 data-[state=open]:opacity-100">
|
||||
className="text-foreground/80 opacity-0 hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 data-[state=open]:opacity-100 [&_svg]:[stroke-width:1.6]">
|
||||
<MoreHorizontal size={12} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -296,7 +296,7 @@ export function ResourceCard({ resource: r, allTagNames, onDelete, onDuplicate,
|
||||
size="icon-sm"
|
||||
aria-label={r.type === 'skill' ? t('library.action.uninstall') : t('common.delete')}
|
||||
onClick={() => onDelete(r)}
|
||||
className="text-foreground-muted opacity-0 hover:bg-error-bg hover:text-error-text focus-visible:opacity-100 group-hover:opacity-100">
|
||||
className="text-foreground/80 opacity-0 hover:bg-error-bg hover:text-error-text focus-visible:opacity-100 group-hover:opacity-100 [&_svg]:[stroke-width:1.6]">
|
||||
<Trash2 size={12} className="lucide-custom" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -266,7 +266,11 @@ export const ResourceGrid: FC<Props> = ({
|
||||
<div className="flex shrink-0 flex-col border-border-muted border-b">
|
||||
<div className="flex items-center gap-2 px-5 py-3">
|
||||
<div className="relative max-w-64 flex-1">
|
||||
<Search size={14} className="-translate-y-1/2 absolute top-1/2 left-2.5 text-foreground-muted" />
|
||||
<Search
|
||||
size={14}
|
||||
strokeWidth={1.6}
|
||||
className="-translate-y-1/2 absolute top-1/2 left-2.5 text-foreground/80"
|
||||
/>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
@@ -279,7 +283,7 @@ export const ResourceGrid: FC<Props> = ({
|
||||
size="icon-sm"
|
||||
aria-label={t('common.clear')}
|
||||
onClick={() => onSearchChange('')}
|
||||
className="-translate-y-1/2 absolute top-1/2 right-1 size-6 text-foreground-muted hover:text-foreground">
|
||||
className="-translate-y-1/2 absolute top-1/2 right-1 size-6 text-foreground/80 hover:text-foreground [&_svg]:[stroke-width:1.6]">
|
||||
<X size={12} />
|
||||
</Button>
|
||||
)}
|
||||
@@ -360,7 +364,7 @@ export const ResourceGrid: FC<Props> = ({
|
||||
aria-label={t('library.toolbar.all_tags')}
|
||||
title={t('library.toolbar.all_tags')}
|
||||
onClick={() => setShowAllTags((value) => !value)}
|
||||
className="size-6 shrink-0 rounded-full text-foreground-muted hover:bg-accent hover:text-foreground">
|
||||
className="size-6 shrink-0 rounded-full text-foreground/80 hover:bg-accent hover:text-foreground [&_svg]:[stroke-width:1.6]">
|
||||
{showAllTags ? <ChevronLeft size={13} /> : <ChevronRight size={13} />}
|
||||
</Button>
|
||||
)}
|
||||
@@ -391,7 +395,7 @@ export const ResourceGrid: FC<Props> = ({
|
||||
size="icon-sm"
|
||||
onClick={() => void handleAddTag()}
|
||||
disabled={addingTag || !newTagName.trim()}
|
||||
className="size-6 text-foreground-muted hover:text-foreground">
|
||||
className="size-6 text-foreground/80 hover:text-foreground [&_svg]:[stroke-width:1.6]">
|
||||
<Plus size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -235,14 +235,14 @@ const HeaderNavbar = ({
|
||||
<RowFlex className="flex-[0_0_auto] items-center">
|
||||
{showWorkspace && (
|
||||
<Tooltip title={t('navbar.hide_sidebar')} delay={800}>
|
||||
<BaseNavbarIcon className="[&_svg]:size-4.5 [&_svg]:text-icon" onClick={handleToggleShowWorkspace}>
|
||||
<BaseNavbarIcon className="[&_svg]:size-4.5 [&_svg]:text-foreground/80" onClick={handleToggleShowWorkspace}>
|
||||
<PanelLeftClose size={18} />
|
||||
</BaseNavbarIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!showWorkspace && (
|
||||
<Tooltip title={t('navbar.show_sidebar')} delay={800} placement="right">
|
||||
<BaseNavbarIcon className="[&_svg]:size-4.5 [&_svg]:text-icon" onClick={handleToggleShowWorkspace}>
|
||||
<BaseNavbarIcon className="[&_svg]:size-4.5 [&_svg]:text-foreground/80" onClick={handleToggleShowWorkspace}>
|
||||
<PanelRightClose size={18} />
|
||||
</BaseNavbarIcon>
|
||||
</Tooltip>
|
||||
@@ -274,7 +274,7 @@ const HeaderNavbar = ({
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block min-w-0 max-w-37.5 shrink overflow-hidden text-ellipsis whitespace-nowrap',
|
||||
item.isFolder && !isLastItem && 'cursor-pointer hover:text-primary hover:underline'
|
||||
item.isFolder && !isLastItem && 'cursor-pointer hover:text-link hover:underline'
|
||||
)}
|
||||
onClick={() => handleBreadcrumbClick(item)}>
|
||||
{item.title}
|
||||
@@ -297,7 +297,7 @@ const HeaderNavbar = ({
|
||||
{canShowStarButton && (
|
||||
<Tooltip title={activeNode.isStarred ? t('notes.unstar') : t('notes.star')} delay={800}>
|
||||
<div
|
||||
className="flex h-7.5 cursor-pointer flex-row items-center justify-center rounded-lg px-1.75 transition-all duration-200 ease-in-out [-webkit-app-region:none] hover:bg-muted [&_svg]:text-icon"
|
||||
className="flex h-7.5 cursor-pointer flex-row items-center justify-center rounded-lg px-1.75 transition-all duration-200 ease-in-out [-webkit-app-region:none] hover:bg-muted [&_svg]:text-foreground/80 [&_svg]:[stroke-width:1.6]"
|
||||
onClick={handleToggleStarred}>
|
||||
{activeNode.isStarred ? (
|
||||
<Star size={18} fill="var(--color-warning-base)" stroke="var(--color-warning-base)" />
|
||||
@@ -311,7 +311,7 @@ const HeaderNavbar = ({
|
||||
<PopoverTrigger asChild>
|
||||
<div>
|
||||
<Tooltip title={t('notes.settings.title')} delay={800}>
|
||||
<BaseNavbarIcon className="[&_svg]:size-4.5 [&_svg]:text-icon">
|
||||
<BaseNavbarIcon className="[&_svg]:size-4.5 [&_svg]:text-foreground/80">
|
||||
<MoreHorizontal size={18} />
|
||||
</BaseNavbarIcon>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { EmptyState, SpaceBetweenRowFlex, Tooltip } from '@cherrystudio/ui'
|
||||
import { Combobox, EmptyState, SpaceBetweenRowFlex, Tooltip } from '@cherrystudio/ui'
|
||||
import { usePreference } from '@data/hooks/usePreference'
|
||||
import { loggerService } from '@logger'
|
||||
import ActionIconButton from '@renderer/components/Buttons/ActionIconButton'
|
||||
import CodeEditor, { type CodeEditorHandles } from '@renderer/components/CodeEditor'
|
||||
import RichEditor from '@renderer/components/RichEditor'
|
||||
import type { RichEditorRef } from '@renderer/components/RichEditor/types'
|
||||
import Selector from '@renderer/components/Selector'
|
||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||
import type { EditorView } from '@renderer/types/app'
|
||||
import { SpellCheck } from 'lucide-react'
|
||||
@@ -144,11 +143,13 @@ const NotesEditor: FC<NotesEditorProps> = memo(
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Selector
|
||||
<Combobox
|
||||
searchable={false}
|
||||
width={130}
|
||||
value={tmpViewMode as EditorView}
|
||||
onChange={(value: EditorView) => {
|
||||
onChange={(value) => {
|
||||
userViewModeOverrideRef.current = true
|
||||
setTmpViewMode(value)
|
||||
setTmpViewMode(value as EditorView)
|
||||
}}
|
||||
options={[
|
||||
{ label: t('notes.settings.editor.edit_mode.preview_mode'), value: 'preview' },
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Button, Input, Slider, Switch } from '@cherrystudio/ui'
|
||||
import { Button, Combobox, Input, Slider, Switch } from '@cherrystudio/ui'
|
||||
import { loggerService } from '@logger'
|
||||
import Selector from '@renderer/components/Selector'
|
||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||
import { useTheme } from '@renderer/hooks/useTheme'
|
||||
import {
|
||||
@@ -129,26 +128,28 @@ const NotesSettings: FC = () => {
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('notes.settings.editor.view_mode.title')}</SettingRowTitle>
|
||||
<Selector
|
||||
<Combobox
|
||||
searchable={false}
|
||||
options={[
|
||||
{ label: t('notes.settings.editor.view_mode.edit_mode'), value: 'edit' },
|
||||
{ label: t('notes.settings.editor.view_mode.read_mode'), value: 'read' }
|
||||
]}
|
||||
value={settings.defaultViewMode}
|
||||
onChange={(value: 'edit' | 'read') => updateSettings({ defaultViewMode: value })}
|
||||
onChange={(value) => updateSettings({ defaultViewMode: value as 'edit' | 'read' })}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingHelpText>{t('notes.settings.editor.view_mode.description')}</SettingHelpText>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('notes.settings.editor.edit_mode.title')}</SettingRowTitle>
|
||||
<Selector
|
||||
<Combobox
|
||||
searchable={false}
|
||||
options={[
|
||||
{ label: t('notes.settings.editor.edit_mode.preview_mode'), value: 'preview' },
|
||||
{ label: t('notes.settings.editor.edit_mode.source_mode'), value: 'source' }
|
||||
]}
|
||||
value={settings.defaultEditMode}
|
||||
onChange={(value: Exclude<EditorView, 'read'>) => updateSettings({ defaultEditMode: value })}
|
||||
onChange={(value) => updateSettings({ defaultEditMode: value as Exclude<EditorView, 'read'> })}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingHelpText>{t('notes.settings.editor.edit_mode.description')}</SettingHelpText>
|
||||
|
||||
@@ -352,7 +352,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
<span>{t('notes.search.searching')}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto flex size-5 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 text-muted-foreground transition-all duration-200 hover:bg-accent hover:text-foreground active:bg-accent"
|
||||
className="ml-auto flex size-5 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 text-foreground/80 transition-all duration-200 hover:bg-accent hover:text-foreground active:bg-accent [&_svg]:[stroke-width:1.6]"
|
||||
onClick={cancel}
|
||||
title={t('common.cancel')}>
|
||||
<X size={14} />
|
||||
|
||||
@@ -63,7 +63,7 @@ const NotesSidebarHeader: FC<NotesSidebarHeaderProps> = ({
|
||||
<>
|
||||
<Tooltip content={t('notes.new_note')} delay={800}>
|
||||
<div
|
||||
className="flex size-6 cursor-pointer items-center justify-center rounded-sm text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
className="flex size-6 cursor-pointer items-center justify-center rounded-sm text-foreground/80 hover:bg-muted hover:text-foreground [&_svg]:[stroke-width:1.6]"
|
||||
onClick={onCreateNote}>
|
||||
<FilePlus2 size={18} />
|
||||
</div>
|
||||
@@ -71,7 +71,7 @@ const NotesSidebarHeader: FC<NotesSidebarHeaderProps> = ({
|
||||
|
||||
<Tooltip content={t('notes.new_folder')} delay={800}>
|
||||
<div
|
||||
className="flex size-6 cursor-pointer items-center justify-center rounded-sm text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
className="flex size-6 cursor-pointer items-center justify-center rounded-sm text-foreground/80 hover:bg-muted hover:text-foreground [&_svg]:[stroke-width:1.6]"
|
||||
onClick={onCreateFolder}>
|
||||
<FolderPlus size={18} />
|
||||
</div>
|
||||
@@ -81,7 +81,7 @@ const NotesSidebarHeader: FC<NotesSidebarHeaderProps> = ({
|
||||
<PopoverTrigger asChild>
|
||||
<div>
|
||||
<Tooltip content={t('assistants.presets.sorting.title')} delay={800}>
|
||||
<div className="flex size-6 cursor-pointer items-center justify-center rounded-sm text-muted-foreground hover:bg-muted hover:text-foreground">
|
||||
<div className="flex size-6 cursor-pointer items-center justify-center rounded-sm text-foreground/80 hover:bg-muted hover:text-foreground [&_svg]:[stroke-width:1.6]">
|
||||
<ArrowUpNarrowWide size={18} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
@@ -111,7 +111,7 @@ const NotesSidebarHeader: FC<NotesSidebarHeaderProps> = ({
|
||||
|
||||
<Tooltip content={t('notes.show_starred')} delay={800}>
|
||||
<div
|
||||
className="flex size-6 cursor-pointer items-center justify-center rounded-sm text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
className="flex size-6 cursor-pointer items-center justify-center rounded-sm text-foreground/80 hover:bg-muted hover:text-foreground [&_svg]:[stroke-width:1.6]"
|
||||
onClick={onToggleStarredView}>
|
||||
<Star size={18} />
|
||||
</div>
|
||||
@@ -119,7 +119,7 @@ const NotesSidebarHeader: FC<NotesSidebarHeaderProps> = ({
|
||||
|
||||
<Tooltip content={t('common.search')} delay={800}>
|
||||
<div
|
||||
className="flex size-6 cursor-pointer items-center justify-center rounded-sm text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
className="flex size-6 cursor-pointer items-center justify-center rounded-sm text-foreground/80 hover:bg-muted hover:text-foreground [&_svg]:[stroke-width:1.6]"
|
||||
onClick={onToggleSearchView}>
|
||||
<Search size={18} />
|
||||
</div>
|
||||
@@ -129,7 +129,7 @@ const NotesSidebarHeader: FC<NotesSidebarHeaderProps> = ({
|
||||
{isShowStarred && (
|
||||
<Tooltip content={t('common.back')} delay={800}>
|
||||
<div
|
||||
className="flex size-6 cursor-pointer items-center justify-center rounded-sm text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
className="flex size-6 cursor-pointer items-center justify-center rounded-sm text-foreground/80 hover:bg-muted hover:text-foreground [&_svg]:[stroke-width:1.6]"
|
||||
onClick={onToggleStarredView}>
|
||||
<ArrowLeft size={18} />
|
||||
</div>
|
||||
@@ -139,7 +139,7 @@ const NotesSidebarHeader: FC<NotesSidebarHeaderProps> = ({
|
||||
<>
|
||||
<Tooltip content={t('common.back')} delay={800}>
|
||||
<div
|
||||
className="flex size-6 cursor-pointer items-center justify-center rounded-sm text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
className="flex size-6 cursor-pointer items-center justify-center rounded-sm text-foreground/80 hover:bg-muted hover:text-foreground [&_svg]:[stroke-width:1.6]"
|
||||
onClick={onToggleSearchView}>
|
||||
<ArrowLeft size={18} />
|
||||
</div>
|
||||
@@ -155,7 +155,7 @@ const NotesSidebarHeader: FC<NotesSidebarHeaderProps> = ({
|
||||
{searchKeyword && (
|
||||
<button
|
||||
type="button"
|
||||
className="-translate-y-1/2 absolute top-1/2 right-1 flex size-5 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
className="-translate-y-1/2 absolute top-1/2 right-1 flex size-5 items-center justify-center rounded text-foreground/80 hover:bg-accent hover:text-foreground [&_svg]:[stroke-width:1.6]"
|
||||
onClick={() => onSetSearchKeyword('')}
|
||||
aria-label={t('common.clear')}>
|
||||
<X size={13} />
|
||||
|
||||
@@ -388,8 +388,8 @@ const OpenClawPage: FC = () => {
|
||||
{/* Install Path - hide when gateway is running */}
|
||||
{installPath && gatewayStatus !== 'running' && (
|
||||
<div
|
||||
className="mb-6 flex items-center justify-between gap-2 rounded-lg px-3 py-2 text-sm"
|
||||
style={{ background: 'var(--color-background-soft)', color: 'var(--color-text-3)' }}>
|
||||
className="mb-6 flex items-center justify-between gap-2 rounded-lg bg-muted/50 px-3 py-2 text-sm"
|
||||
style={{ color: 'var(--color-text-3)' }}>
|
||||
<div className="min-w-0 shrink overflow-hidden">
|
||||
<div className="mb-1">{t('openclaw.installed_at')}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -399,7 +399,7 @@ const OpenClawPage: FC = () => {
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="size-4! p-0! shadow-none"
|
||||
className="size-4! p-0! text-foreground-muted shadow-none hover:text-foreground"
|
||||
aria-label={t('common.copy')}
|
||||
onClick={async () => {
|
||||
try {
|
||||
@@ -507,12 +507,14 @@ const OpenClawPage: FC = () => {
|
||||
filter={modelFilter}
|
||||
onSelect={handleModelSelect}
|
||||
trigger={
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-8 w-full justify-between gap-2 rounded-lg bg-muted/50 px-2.5 font-normal text-sm hover:bg-muted">
|
||||
{selectedModel ? <ModelAvatar model={selectedModel} size={18} /> : null}
|
||||
<span className="flex-1 truncate text-left">
|
||||
{selectedModel ? selectedModel.name : t('openclaw.model_config.select_model')}
|
||||
</span>
|
||||
<ChevronDown size={14} className="text-muted-foreground" />
|
||||
<ChevronDown size={16} className="lucide-custom text-muted-foreground/40" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -522,8 +524,8 @@ const OpenClawPage: FC = () => {
|
||||
|
||||
{/* Tips about OpenClaw */}
|
||||
<div
|
||||
className="mt-4 rounded-lg p-3 text-xs leading-relaxed"
|
||||
style={{ background: 'var(--color-background-mute)', color: 'var(--color-text-3)' }}>
|
||||
className="mt-4 rounded-lg bg-muted/50 p-3 text-xs leading-relaxed"
|
||||
style={{ color: 'var(--color-text-3)' }}>
|
||||
<div className="mb-1">💡 {t('openclaw.tips.title')}</div>
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
<li>{t('openclaw.tips.permissions')}</li>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Button, Tooltip } from '@cherrystudio/ui'
|
||||
import ImageViewer from '@renderer/components/ImageViewer'
|
||||
import { ImageDown, ImageUp, RefreshCcw, RotateCcwSquare, RotateCwSquare, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { ImageDown, ImageUp, Loader2, RefreshCcw, RotateCcwSquare, RotateCwSquare, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
import {
|
||||
type FC,
|
||||
type PointerEvent,
|
||||
@@ -46,19 +45,8 @@ const LoadingStateCard: FC<{ text: ReactNode; onCancel: () => void; cancelLabel:
|
||||
cancelLabel
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex min-w-56 flex-col items-center gap-4 rounded-[18px] border border-border-subtle bg-card/96 px-10 py-10 shadow-2xl backdrop-blur-sm">
|
||||
<div className="relative h-12 w-12">
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full border-2 border-border"
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 2, repeat: Number.POSITIVE_INFINITY, ease: 'linear' }}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute inset-1 rounded-full border-2 border-primary border-r-transparent border-b-transparent"
|
||||
animate={{ rotate: -360 }}
|
||||
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY, ease: 'linear' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-56 flex-col items-center gap-4 rounded-2xl border border-border bg-popover px-10 py-10 shadow-lg">
|
||||
<Loader2 className="size-8 animate-spin text-primary" />
|
||||
<div className="text-center font-medium text-[13px] text-foreground/85">{text}</div>
|
||||
<Button variant="outline" size="sm" onClick={onCancel} className="mt-1 min-w-20">
|
||||
{cancelLabel}
|
||||
@@ -192,8 +180,10 @@ const Artboard: FC<ArtboardProps> = ({ painting, isLoading, onCancel, imageCover
|
||||
<div className="relative flex min-h-0 w-full flex-1 items-center justify-center overflow-hidden">
|
||||
<ImageViewer
|
||||
alt=""
|
||||
className={`max-h-full max-w-full select-none rounded-md bg-secondary object-contain ${
|
||||
isDraggingImage ? 'cursor-grabbing transition-none' : 'cursor-grab transition-transform duration-150'
|
||||
className={`max-h-full max-w-full select-none rounded-md bg-secondary object-contain ${isLoading ? 'blur-md' : ''} ${
|
||||
isDraggingImage
|
||||
? 'cursor-grabbing transition-none'
|
||||
: 'cursor-grab transition-[transform,filter] duration-150'
|
||||
}`}
|
||||
draggable={false}
|
||||
onPointerCancel={stopImageDrag}
|
||||
@@ -214,10 +204,10 @@ const Artboard: FC<ArtboardProps> = ({ painting, isLoading, onCancel, imageCover
|
||||
{painting.files.length > 1 && (
|
||||
<>
|
||||
<ArtboardToolButton label={t('preview.previous')} onClick={onPrevImage}>
|
||||
<ImageUp className="size-[18px]" />
|
||||
<ImageUp className="size-[18px] text-foreground" strokeWidth={1.6} />
|
||||
</ArtboardToolButton>
|
||||
<ArtboardToolButton label={t('preview.next')} onClick={onNextImage}>
|
||||
<ImageDown className="size-[18px]" />
|
||||
<ImageDown className="size-[18px] text-foreground" strokeWidth={1.6} />
|
||||
</ArtboardToolButton>
|
||||
<span className="my-0.5 h-px w-4 bg-border-subtle" aria-hidden />
|
||||
</>
|
||||
@@ -226,22 +216,22 @@ const Artboard: FC<ArtboardProps> = ({ painting, isLoading, onCancel, imageCover
|
||||
label={t('preview.zoom_out')}
|
||||
disabled={imageScale <= MIN_IMAGE_SCALE}
|
||||
onClick={zoomOut}>
|
||||
<ZoomOut className="size-4" />
|
||||
<ZoomOut className="size-4 text-foreground" strokeWidth={1.6} />
|
||||
</ArtboardToolButton>
|
||||
<ArtboardToolButton
|
||||
label={t('preview.zoom_in')}
|
||||
disabled={imageScale >= MAX_IMAGE_SCALE}
|
||||
onClick={zoomIn}>
|
||||
<ZoomIn className="size-4" />
|
||||
<ZoomIn className="size-4 text-foreground" strokeWidth={1.6} />
|
||||
</ArtboardToolButton>
|
||||
<ArtboardToolButton label={t('preview.rotate_left')} onClick={rotateImageLeft}>
|
||||
<RotateCcwSquare className="size-4" />
|
||||
<RotateCcwSquare className="size-4 text-foreground" strokeWidth={1.6} />
|
||||
</ArtboardToolButton>
|
||||
<ArtboardToolButton label={t('preview.rotate_right')} onClick={rotateImageRight}>
|
||||
<RotateCwSquare className="size-4" />
|
||||
<RotateCwSquare className="size-4 text-foreground" strokeWidth={1.6} />
|
||||
</ArtboardToolButton>
|
||||
<ArtboardToolButton label={t('preview.reset')} onClick={resetImageTransform}>
|
||||
<RefreshCcw className="size-4" />
|
||||
<RefreshCcw className="size-4 text-foreground" strokeWidth={1.6} />
|
||||
</ArtboardToolButton>
|
||||
</div>
|
||||
<div className="-translate-x-1/2 absolute bottom-2.5 left-1/2 rounded-full bg-foreground/60 px-2 py-1 text-background text-xs">
|
||||
|
||||
@@ -132,7 +132,7 @@ const PaintingParamsButton: FC<{
|
||||
size="sm"
|
||||
className={cn(COMPOSER_SELECTOR_BUTTON_CLASS, 'text-muted-foreground')}
|
||||
aria-label={summary ? `${t('common.settings')}: ${summary}` : t('common.settings')}>
|
||||
<Settings2 className="size-4" />
|
||||
<Settings2 className="size-4 text-foreground" strokeWidth={1.6} />
|
||||
{summary && (
|
||||
<span className="max-w-55 truncate" title={summary}>
|
||||
{summary}
|
||||
@@ -140,7 +140,7 @@ const PaintingParamsButton: FC<{
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" side="top" className="w-[min(340px,calc(100vw-2rem))] rounded-[8px] p-3">
|
||||
<PopoverContent align="start" side="top" className="w-[min(340px,calc(100vw-2rem))] p-3">
|
||||
<div className="flex max-h-[60vh] flex-col gap-4 overflow-y-auto pr-1">
|
||||
<PaintingSettings
|
||||
painting={painting}
|
||||
|
||||
@@ -81,13 +81,13 @@ const PaintingModelSelector: FC<PaintingModelSelectorProps> = ({ className, pain
|
||||
showPinnedModels={false}
|
||||
showPinActions={false}
|
||||
prioritizedProviderIds={painting.providerId ? [painting.providerId] : undefined}
|
||||
contentClassName="w-[min(420px,calc(100vw-2rem))] rounded-[8px]"
|
||||
contentClassName="w-[min(420px,calc(100vw-2rem))]"
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-auto w-full max-w-none justify-between gap-2 rounded-[8px] border border-border-subtle bg-secondary px-2.5 py-1.5 text-muted-foreground text-xs shadow-none hover:bg-secondary-hover hover:text-foreground',
|
||||
'h-auto w-full max-w-none justify-between gap-2 rounded-[8px] border border-border-subtle bg-transparent px-2.5 py-1.5 text-muted-foreground text-xs shadow-none hover:bg-secondary-hover hover:text-foreground',
|
||||
className
|
||||
)}>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||
|
||||
@@ -119,7 +119,7 @@ const PaintingStrip: FC<PaintingStripProps> = ({
|
||||
className={paintingClasses.historyAddButton}
|
||||
aria-label={t('paintings.button.new.image')}
|
||||
onClick={onAddPainting}>
|
||||
<Plus className="size-4" />
|
||||
<Plus className="size-4 text-foreground" strokeWidth={1.6} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{items.map((painting) => (
|
||||
|
||||
@@ -26,9 +26,7 @@ export default function SelectField({
|
||||
|
||||
return (
|
||||
<Select disabled={disabled} value={value} onValueChange={(nextValue) => onChange({ [fieldKey]: nextValue })}>
|
||||
<SelectTrigger
|
||||
aria-label={item.title ? translate(item.title) : fieldKey}
|
||||
className="h-auto w-full justify-between gap-2 rounded-[8px] bg-secondary px-2.5 py-1.5 text-xs hover:bg-secondary-hover">
|
||||
<SelectTrigger size="sm" aria-label={item.title ? translate(item.title) : fieldKey} className="w-full">
|
||||
<SelectValue placeholder={item.title ? translate(item.title) : fieldKey} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { cn } from '@cherrystudio/ui/lib/utils'
|
||||
|
||||
import type { OptionItem } from '../../form/baseConfigItem'
|
||||
import type { PaintingFieldComponentProps } from '../fieldRegistry'
|
||||
import { resolveOptions } from '../resolveOptions'
|
||||
|
||||
const MAX_THUMB = 14
|
||||
const MIN_THUMB = 6
|
||||
const DEFAULT_COLUMNS = 3
|
||||
const MAX_THUMB = 12
|
||||
const MIN_THUMB = 5
|
||||
|
||||
const chipClass = {
|
||||
base: 'flex min-h-10 min-w-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-[10px] px-1 py-1 text-[11px] leading-tight transition-all',
|
||||
active: 'bg-secondary-active text-foreground ring-1 ring-[var(--color-border-active)]',
|
||||
inactive: 'bg-muted text-muted-foreground/60 hover:bg-secondary-hover hover:text-foreground',
|
||||
base: 'flex min-h-9 min-w-11 shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-md border border-border px-2 py-1.5 text-[11px] leading-tight transition-all',
|
||||
active: 'bg-accent text-foreground',
|
||||
inactive: 'text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
disabled: 'cursor-not-allowed opacity-50'
|
||||
}
|
||||
|
||||
@@ -48,8 +46,7 @@ function splitParens(label: string): { head: string; inner: string } {
|
||||
*
|
||||
* - Pure aspect-ratio enum (`ASPECT_X_Y` from `supports.aspectRatio`,
|
||||
* or bare `X:Y` / `X_Y`) → `X:Y`. Prevents the raw enum from
|
||||
* leaking into the UI ("ASPECT_1_1") and keeps the chip width
|
||||
* bounded so the grid follows the parent container.
|
||||
* leaking into the UI ("ASPECT_1_1") and keeps the chip compact.
|
||||
* - Label with parenthesized pixel dims like `"1:1 (1024×1024)"` →
|
||||
* use the head (`"1:1"`).
|
||||
* - Pixel-size value `WxH` → `W×H` (formatted with U+00D7).
|
||||
@@ -82,7 +79,10 @@ function RatioShape({ ratio, selected }: { ratio: Dim; selected: boolean }) {
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-block rounded-[2px] border border-current transition-all', !selected && 'opacity-40')}
|
||||
className={cn(
|
||||
'inline-block rounded-[2px] border-[0.5px] border-current transition-all',
|
||||
!selected && 'opacity-70'
|
||||
)}
|
||||
style={{ width: w, height: h }}
|
||||
/>
|
||||
)
|
||||
@@ -100,23 +100,6 @@ function RatioThumb({ value, selected }: { value: string; selected: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a column count that keeps every chip readable. The sidebar's fixed
|
||||
* width can't accommodate three 9-char labels (`1280×1280`) — minmax(0, 1fr)
|
||||
* columns shrink and the chip text overflows / truncates. Drop to 2 cols
|
||||
* when any derived label is at least 8 chars. Aspect-ratio chips (`1:1`)
|
||||
* stay at 3 cols since their labels are short.
|
||||
*/
|
||||
function autoColumns(options: OptionItem[], explicit: number | undefined): number {
|
||||
if (explicit) return explicit
|
||||
let longest = 0
|
||||
for (const option of options) {
|
||||
const label = deriveChipLabel(String(option.label ?? option.value), String(option.value))
|
||||
if (label.length > longest) longest = label.length
|
||||
}
|
||||
return longest >= 8 ? 2 : DEFAULT_COLUMNS
|
||||
}
|
||||
|
||||
export default function SizeChipsField({
|
||||
item,
|
||||
fieldKey,
|
||||
@@ -128,10 +111,9 @@ export default function SizeChipsField({
|
||||
}: PaintingFieldComponentProps) {
|
||||
const options = resolveOptions(item, painting, translate)
|
||||
const value = currentValue == null ? '' : String(currentValue)
|
||||
const columns = autoColumns(options, item.columns)
|
||||
|
||||
return (
|
||||
<div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{options.map((option) => {
|
||||
const optionValue = String(option.value)
|
||||
const label = option.label || optionValue
|
||||
|
||||
@@ -15,7 +15,7 @@ export const paintingClasses = {
|
||||
historyStrip:
|
||||
'flex h-full w-[68px] shrink-0 flex-col gap-2 overflow-y-auto border-border-subtle border-r bg-background px-2 py-2 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden',
|
||||
historyAddButton:
|
||||
'sticky top-0 z-10 mb-1 flex h-11 w-11 shrink-0 items-center justify-center rounded-[12px] border border-dashed border-border-muted bg-background text-muted-foreground hover:bg-secondary-hover hover:text-foreground',
|
||||
'sticky top-0 z-10 mb-1 flex h-9 w-11 shrink-0 items-center justify-center rounded-full bg-background text-foreground/80 hover:bg-accent hover:text-foreground [&_svg]:[stroke-width:1.6]',
|
||||
historyItem:
|
||||
'group relative flex h-11 w-11 shrink-0 items-center justify-center overflow-visible rounded-[12px] bg-secondary p-0 leading-none transition hover:bg-secondary-hover',
|
||||
historyItemActive: 'bg-background',
|
||||
@@ -27,7 +27,7 @@ export const paintingClasses = {
|
||||
promptWrap: 'shrink-0 px-2 pb-4 pt-2',
|
||||
toolbarWrap: 'absolute top-1/2 left-4 z-20 -translate-y-1/2',
|
||||
toolbarRail:
|
||||
'flex flex-col items-center gap-1 rounded-full border border-border-muted bg-background/90 p-1 shadow-md backdrop-blur-xl',
|
||||
'flex flex-col items-center gap-1 rounded-full border border-border-muted bg-background/90 p-1 shadow-lg backdrop-blur-xl',
|
||||
toolbarButton: 'rounded-full text-muted-foreground hover:bg-muted/55 hover:text-foreground',
|
||||
toolbarButtonActive: 'bg-muted text-foreground'
|
||||
} as const
|
||||
|
||||
@@ -26,7 +26,6 @@ import { type FileMetadata, isImageFileMetadata } from '@renderer/types/file'
|
||||
import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
|
||||
import { getFileExtension, isTextFile } from '@renderer/utils/file'
|
||||
import { getFilesFromDropEvent, getTextFromDropEvent } from '@renderer/utils/input'
|
||||
import { cn } from '@renderer/utils/style'
|
||||
import {
|
||||
createInputScrollHandler,
|
||||
createOutputScrollHandler,
|
||||
@@ -679,27 +678,15 @@ const TranslatePage: FC = () => {
|
||||
onExchange={handleExchange}
|
||||
/>
|
||||
{isTranslating ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAbort}
|
||||
className="flex h-8 items-center gap-1.5 rounded-md bg-secondary px-3 text-foreground text-sm transition-all hover:bg-secondary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50">
|
||||
<Button type="button" variant="secondary" size="sm" onClick={onAbort}>
|
||||
<CirclePause size={14} className="lucide-custom" />
|
||||
<span>{t('common.stop')}</span>
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTranslate}
|
||||
disabled={!couldTranslate}
|
||||
className={cn(
|
||||
'flex h-8 items-center gap-1.5 rounded-md px-3 text-sm transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50',
|
||||
couldTranslate
|
||||
? 'bg-primary text-primary-foreground hover:opacity-90'
|
||||
: 'cursor-not-allowed bg-muted text-foreground-muted'
|
||||
)}>
|
||||
<Button type="button" size="sm" onClick={onTranslate} disabled={!couldTranslate}>
|
||||
<Languages size={14} className="lucide-custom" />
|
||||
<span>{t('translate.button.translate')}</span>
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -742,7 +729,9 @@ const TranslatePage: FC = () => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={historyOpen ? 'text-foreground' : 'text-foreground-muted hover:text-foreground'}
|
||||
className={
|
||||
historyOpen ? 'text-foreground' : 'text-foreground/80 hover:text-foreground [&_svg]:[stroke-width:1.6]'
|
||||
}
|
||||
onClick={() =>
|
||||
setHistoryOpen((open) => {
|
||||
const next = !open
|
||||
@@ -757,7 +746,9 @@ const TranslatePage: FC = () => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={settingsOpen ? 'text-foreground' : 'text-foreground-muted hover:text-foreground'}
|
||||
className={
|
||||
settingsOpen ? 'text-foreground' : 'text-foreground/80 hover:text-foreground [&_svg]:[stroke-width:1.6]'
|
||||
}
|
||||
onClick={() =>
|
||||
setSettingsOpen((open) => {
|
||||
const next = !open
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldLabel,
|
||||
HelpTooltip,
|
||||
InfoTooltip,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
@@ -142,7 +142,7 @@ const TranslateSettings: FC<Props> = ({ visible, onClose }) => {
|
||||
title={
|
||||
<span className="flex items-center gap-1">
|
||||
<span>{t('translate.detect.method.label')}</span>
|
||||
<HelpTooltip
|
||||
<InfoTooltip
|
||||
content={t('translate.detect.method.tip')}
|
||||
iconProps={{ className: 'text-foreground-muted' }}
|
||||
/>
|
||||
@@ -172,7 +172,7 @@ const TranslateSettings: FC<Props> = ({ visible, onClose }) => {
|
||||
title={
|
||||
<span className="flex items-center gap-1">
|
||||
<span>{t('translate.settings.bidirectional')}</span>
|
||||
<HelpTooltip
|
||||
<InfoTooltip
|
||||
content={t('translate.settings.bidirectional_tip')}
|
||||
iconProps={{ className: 'text-foreground-muted' }}
|
||||
/>
|
||||
@@ -309,7 +309,7 @@ const TranslatePromptField: FC = () => {
|
||||
<textarea
|
||||
value={local}
|
||||
onChange={(e) => schedulePersist(e.target.value)}
|
||||
className="min-h-30 w-full resize-y rounded-md border border-border-subtle bg-muted/40 p-3 text-foreground-secondary text-sm leading-relaxed outline-none transition-colors focus:border-border-hover"
|
||||
className="min-h-30 w-full resize-y overflow-auto rounded-md border border-border-subtle bg-muted/40 p-3 text-foreground-secondary text-sm leading-relaxed outline-none transition-colors focus:border-border-hover"
|
||||
/>
|
||||
</PageSidePanelSection>
|
||||
)
|
||||
|
||||
@@ -61,7 +61,7 @@ vi.mock('@cherrystudio/ui', () => ({
|
||||
Field: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
FieldDescription: ({ children, ...props }: React.ComponentProps<'p'>) => <p {...props}>{children}</p>,
|
||||
FieldLabel: ({ children, ...props }: React.ComponentProps<'label'>) => <label {...props}>{children}</label>,
|
||||
HelpTooltip: () => null,
|
||||
InfoTooltip: () => null,
|
||||
Input: ({ ...props }: React.ComponentProps<'input'>) => <input {...props} />,
|
||||
InputGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
InputGroupAddon: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
|
||||
@@ -22,12 +22,12 @@ const SIZE_CLASS: Record<IconButtonSize, string> = {
|
||||
|
||||
const toneClass = (tone: IconButtonTone, active: boolean): string => {
|
||||
if (tone === 'destructive') {
|
||||
return 'text-foreground-muted hover:bg-accent hover:text-destructive'
|
||||
return 'text-foreground/80 hover:bg-accent hover:text-destructive'
|
||||
}
|
||||
if (tone === 'star') {
|
||||
return active ? 'text-amber-500 bg-amber-500/10' : 'text-foreground-muted hover:bg-accent hover:text-amber-500'
|
||||
return active ? 'text-amber-500 bg-amber-500/10' : 'text-foreground/80 hover:bg-accent hover:text-amber-500'
|
||||
}
|
||||
return active ? 'bg-accent text-foreground' : 'text-foreground-muted hover:bg-accent hover:text-foreground'
|
||||
return active ? 'bg-accent text-foreground' : 'text-foreground/80 hover:bg-accent hover:text-foreground'
|
||||
}
|
||||
|
||||
const IconButton = ({
|
||||
@@ -50,7 +50,7 @@ const IconButton = ({
|
||||
type={type ?? 'button'}
|
||||
title={rest.title}
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center transition-colors',
|
||||
'flex shrink-0 items-center justify-center transition-colors [&_svg]:[stroke-width:1.6]',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50',
|
||||
'disabled:cursor-not-allowed disabled:opacity-60',
|
||||
SIZE_CLASS[size],
|
||||
|
||||
@@ -276,7 +276,7 @@ const HistoryRow: FC<{
|
||||
onSelect(item.id)
|
||||
}
|
||||
}}
|
||||
className="group relative flex w-full cursor-pointer flex-col gap-1.5 rounded-md p-2.5 text-left transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50">
|
||||
className="group relative flex w-full cursor-pointer flex-col gap-1.5 rounded-lg p-2.5 text-left transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50">
|
||||
<IconButton
|
||||
size="sm"
|
||||
tone="star"
|
||||
@@ -294,11 +294,11 @@ const HistoryRow: FC<{
|
||||
<Star size={10} className={cn(item.star && 'fill-amber-500')} />
|
||||
</IconButton>
|
||||
<div className="flex items-center gap-1.5 pr-5">
|
||||
<span className="rounded bg-muted px-1 py-px text-muted-foreground text-xs">
|
||||
<span className="rounded bg-muted/50 px-1 py-px text-muted-foreground text-xs">
|
||||
{item._sourceEmoji} {item._sourceLabel}
|
||||
</span>
|
||||
<ArrowRight size={8} className="text-foreground-muted" />
|
||||
<span className="rounded bg-primary/10 px-1 py-px text-primary text-xs">
|
||||
<span className="rounded bg-primary/5 px-1 py-px text-primary text-xs">
|
||||
{item._targetEmoji} {item._targetLabel}
|
||||
</span>
|
||||
<span className="ml-auto text-foreground-muted text-xs">{item._createdAtLabel}</span>
|
||||
|
||||
@@ -178,7 +178,7 @@ const TranslateLanguageBar: FC<Props> = ({
|
||||
onClick={onExchange}
|
||||
disabled={!couldExchange}
|
||||
aria-label={t('translate.exchange.label')}
|
||||
className="h-8 w-8 shrink-0 rounded-full text-foreground-muted shadow-none transition-all hover:bg-accent hover:text-foreground active:scale-90">
|
||||
className="h-8 w-8 shrink-0 rounded-full text-foreground/80 shadow-none transition-all hover:bg-accent hover:text-foreground active:scale-90 [&_svg]:[stroke-width:1.6]">
|
||||
<ArrowLeftRight size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -507,7 +507,6 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
||||
ref={inputBarRef}
|
||||
/>
|
||||
)}
|
||||
<Separator className="my-2.5" />
|
||||
<ClipboardPreview referenceText={referenceText} clearClipboard={clearClipboard} t={t} />
|
||||
<main className="flex flex-1 flex-col overflow-hidden">
|
||||
<FeatureMenus
|
||||
@@ -517,7 +516,6 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
||||
ref={featureMenusRef}
|
||||
/>
|
||||
</main>
|
||||
<Separator className="my-2.5" />
|
||||
<Footer
|
||||
key="footer"
|
||||
{...baseFooterProps}
|
||||
@@ -531,7 +529,7 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
||||
|
||||
const containerClassName = (draggable: boolean) =>
|
||||
cn(
|
||||
'flex h-full w-full flex-1 flex-col px-2.5 py-2',
|
||||
'flex h-full w-full flex-1 flex-col rounded-2xl px-2.5 py-2',
|
||||
draggable ? '[-webkit-app-region:drag]' : '[-webkit-app-region:no-drag]'
|
||||
)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const ClipboardPreview: FC<ClipboardPreviewProps> = ({ referenceText, clearClipb
|
||||
if (!referenceText) return null
|
||||
|
||||
return (
|
||||
<div className="mb-2.5 rounded-lg bg-muted p-3">
|
||||
<div className="mb-2.5 rounded-2xl bg-muted p-3">
|
||||
<div className="flex w-full items-center text-foreground-secondary">
|
||||
<Copy className="nodrag size-3.5 shrink-0 cursor-pointer" />
|
||||
<p className="nodrag mx-3 min-w-0 flex-1 overflow-hidden text-xs [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]">
|
||||
|
||||
@@ -93,7 +93,7 @@ const FeatureMenus = ({
|
||||
key={index}
|
||||
onClick={feature.onClick}
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer select-none flex-row items-center gap-3 rounded-lg border-0 bg-transparent px-4 py-2 text-left transition-colors [-webkit-app-region:no-drag] hover:bg-accent',
|
||||
'flex w-full cursor-pointer select-none flex-row items-center gap-3 rounded-xl border-0 bg-transparent px-4 py-2 text-left transition-colors [-webkit-app-region:no-drag] hover:bg-accent',
|
||||
index === selectedIndex && 'bg-accent'
|
||||
)}>
|
||||
<span className="flex shrink-0 items-center justify-center">{feature.icon}</span>
|
||||
|
||||
@@ -29,8 +29,10 @@ const InputBar = ({
|
||||
setTimeoutTimer('focus', () => inputRef.current?.focus(), 0)
|
||||
}
|
||||
return (
|
||||
<div ref={ref} className="mt-2.5 flex items-center gap-2">
|
||||
{model && <ModelAvatar model={model} size={30} />}
|
||||
<div
|
||||
ref={ref}
|
||||
className="mt-2.5 mb-3 flex items-center gap-2 rounded-2xl border border-[color:var(--color-border-fg-muted)] px-3.5 py-1.5">
|
||||
{model && <ModelAvatar model={model} size={24} />}
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={text}
|
||||
@@ -38,7 +40,7 @@ const InputBar = ({
|
||||
autoFocus
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
className="h-auto border-0 bg-transparent px-0 py-0 text-lg shadow-none [-webkit-app-region:no-drag] placeholder:text-muted-foreground focus-visible:border-transparent focus-visible:ring-0"
|
||||
className="h-auto border-0 bg-transparent px-1.5 py-0 text-lg shadow-none [-webkit-app-region:no-drag] placeholder:text-muted-foreground focus-visible:border-transparent focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user