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:
Siin Xu
2026-07-02 20:39:40 -07:00
parent caa358d489
commit 8276fb8494
50 changed files with 341 additions and 490 deletions

View File

@@ -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',

View File

@@ -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}

View File

@@ -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>
</>
)}

View File

@@ -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>

View File

@@ -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'
)}>

View File

@@ -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')
})
})

View 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()}>

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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 />

View File

@@ -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 />

View File

@@ -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 ? (

View File

@@ -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>
)

View File

@@ -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>
)

View File

@@ -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) => {

View File

@@ -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()}

View File

@@ -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: '删除' }))

View File

@@ -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>

View File

@@ -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()

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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'

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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' },

View File

@@ -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>

View File

@@ -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} />

View File

@@ -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} />

View File

@@ -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>

View File

@@ -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">

View File

@@ -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}

View File

@@ -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">

View File

@@ -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) => (

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>
)

View File

@@ -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>,

View File

@@ -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],

View File

@@ -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>

View File

@@ -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>

View File

@@ -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]'
)

View File

@@ -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]">

View File

@@ -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>

View File

@@ -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>
)