feat: globalize Magic Notes and improve runtime tools
This commit is contained in:
@@ -13,6 +13,7 @@ import type {
|
||||
BrowserLiveState,
|
||||
DesktopApi
|
||||
} from '../../shared/contracts'
|
||||
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
|
||||
|
||||
const speechRecognitionMocks = vi.hoisted(() => ({
|
||||
startPcmRecording: vi.fn()
|
||||
@@ -471,14 +472,10 @@ const api: DesktopApi = {
|
||||
analyze: vi.fn(async () => {
|
||||
throw new Error('not used')
|
||||
}),
|
||||
analyzeDraft: vi.fn(async () => {
|
||||
throw new Error('not used')
|
||||
}),
|
||||
listTodos: vi.fn(async () => ({ todos: [] })),
|
||||
createTodo: vi.fn(async () => {
|
||||
throw new Error('not used')
|
||||
}),
|
||||
updateTodo: vi.fn(async () => {
|
||||
throw new Error('not used')
|
||||
}),
|
||||
removeTodo: vi.fn(async () => {}),
|
||||
analyzeTodo: vi.fn(async () => {
|
||||
throw new Error('not used')
|
||||
})
|
||||
@@ -612,11 +609,13 @@ describe('App', () => {
|
||||
api.updates = {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
magicNotesEnabled: true
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const
|
||||
})),
|
||||
updateSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
magicNotesEnabled: true
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const
|
||||
})),
|
||||
check,
|
||||
openReleasePage: vi.fn(async () => {}),
|
||||
@@ -657,11 +656,13 @@ describe('App', () => {
|
||||
api.updates = {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
magicNotesEnabled: true
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const
|
||||
})),
|
||||
updateSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
magicNotesEnabled: true
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const
|
||||
})),
|
||||
check,
|
||||
openReleasePage: vi.fn(async () => {}),
|
||||
@@ -3877,15 +3878,17 @@ describe('App', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('opens Magic Notes as a scoped first-class workspace', async () => {
|
||||
it('opens Magic Notes as a global first-class workspace', async () => {
|
||||
api.updates = {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: true
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const
|
||||
})),
|
||||
updateSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: true
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const
|
||||
})),
|
||||
check: vi.fn(),
|
||||
openReleasePage: vi.fn(async () => {}),
|
||||
@@ -3903,7 +3906,7 @@ describe('App', () => {
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: '魔法笔记' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('项目:默认项目')).toBeInTheDocument()
|
||||
expect(screen.getByText('全局')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: '新建笔记' })
|
||||
).toBeInTheDocument()
|
||||
@@ -3920,11 +3923,13 @@ describe('App', () => {
|
||||
api.updates = {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: false
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate' as const
|
||||
})),
|
||||
updateSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: false
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate' as const
|
||||
})),
|
||||
check: vi.fn(),
|
||||
openReleasePage: vi.fn(async () => {}),
|
||||
@@ -3951,9 +3956,10 @@ describe('App', () => {
|
||||
})
|
||||
|
||||
it('keeps platform-feature switches in Settings without navigating', async () => {
|
||||
let applicationSettings = {
|
||||
let applicationSettings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: false
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate'
|
||||
}
|
||||
api.updates = {
|
||||
getSettings: vi.fn(async () => ({ ...applicationSettings })),
|
||||
|
||||
@@ -5739,12 +5739,7 @@ function App(): React.JSX.Element {
|
||||
</PageShell>
|
||||
) : view === 'magic-notes' && magicNotesEnabled ? (
|
||||
<PageShell variant="master-detail">
|
||||
<MagicNotesWorkspace
|
||||
key={activeProject?.id ?? 'global'}
|
||||
onNotify={notify}
|
||||
projectId={activeProject?.id}
|
||||
projectName={activeProject?.name}
|
||||
/>
|
||||
<MagicNotesWorkspace onNotify={notify} />
|
||||
</PageShell>
|
||||
) : view === 'knowledge' ? (
|
||||
<PageShell variant="master-detail">
|
||||
|
||||
@@ -266,12 +266,13 @@ export function KnowledgePanel({
|
||||
</div>
|
||||
<button
|
||||
aria-label={`删除 ${document.name}`}
|
||||
className="icon-button"
|
||||
className="danger-button danger-button--quiet"
|
||||
disabled={busy}
|
||||
onClick={() => void removeDocument(document.id)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={16} />
|
||||
删除
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -1142,6 +1142,7 @@ function DocumentsView({
|
||||
type="button"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
@@ -1968,6 +1969,7 @@ function GraphView({
|
||||
type="button"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={13} />
|
||||
删除
|
||||
</button>
|
||||
{other && (
|
||||
<button
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type ClipboardEvent as ReactClipboardEvent,
|
||||
type DragEvent as ReactDragEvent
|
||||
} from 'react'
|
||||
import Quill from 'quill'
|
||||
import Quill, { type Delta, type EmitterSource } from 'quill'
|
||||
import 'quill/dist/quill.snow.css'
|
||||
import {
|
||||
MAGIC_NOTE_MAX_IMAGES,
|
||||
@@ -28,6 +28,7 @@ export type MagicNoteEditorProps = {
|
||||
ariaLabel: string
|
||||
onChange: (content: MagicNoteRichContent) => void
|
||||
onError: (message: string) => void
|
||||
onParagraphCommit?: (content: MagicNoteRichContent) => void
|
||||
}
|
||||
|
||||
function readFileAsDataUrl(file: File): Promise<string> {
|
||||
@@ -55,7 +56,8 @@ export function MagicNoteEditor({
|
||||
ariaInvalid = false,
|
||||
ariaLabel,
|
||||
onChange,
|
||||
onError
|
||||
onError,
|
||||
onParagraphCommit
|
||||
}: MagicNoteEditorProps): React.JSX.Element {
|
||||
const toolbarRef = useRef<HTMLDivElement>(null)
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
@@ -63,11 +65,13 @@ export function MagicNoteEditor({
|
||||
const quillRef = useRef<Quill | null>(null)
|
||||
const onChangeRef = useRef(onChange)
|
||||
const onErrorRef = useRef(onError)
|
||||
const onParagraphCommitRef = useRef(onParagraphCommit)
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange
|
||||
onErrorRef.current = onError
|
||||
}, [onChange, onError])
|
||||
onParagraphCommitRef.current = onParagraphCommit
|
||||
}, [onChange, onError, onParagraphCommit])
|
||||
|
||||
const insertImages = async (files: File[]): Promise<void> => {
|
||||
const quill = quillRef.current
|
||||
@@ -174,11 +178,30 @@ export function MagicNoteEditor({
|
||||
if (initialContent) {
|
||||
quill.setContents(initialContent.ops, 'silent')
|
||||
}
|
||||
const handleChange = (): void => {
|
||||
onChangeRef.current(richContentFromQuill(quill))
|
||||
const emitChange = (): MagicNoteRichContent => {
|
||||
const content = richContentFromQuill(quill)
|
||||
onChangeRef.current(content)
|
||||
return content
|
||||
}
|
||||
const handleChange = (
|
||||
delta: Delta,
|
||||
_oldContent: Delta,
|
||||
source: EmitterSource
|
||||
): void => {
|
||||
const content = emitChange()
|
||||
if (
|
||||
source === 'user' &&
|
||||
delta.ops.some(
|
||||
(operation) =>
|
||||
typeof operation.insert === 'string' &&
|
||||
operation.insert.includes('\n')
|
||||
)
|
||||
) {
|
||||
onParagraphCommitRef.current?.(content)
|
||||
}
|
||||
}
|
||||
quill.on('text-change', handleChange)
|
||||
handleChange()
|
||||
emitChange()
|
||||
return () => {
|
||||
quill.off('text-change', handleChange)
|
||||
quillRef.current = null
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
} from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { DesktopApi } from '../../shared/contracts'
|
||||
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
|
||||
import type {
|
||||
MagicNoteDetail,
|
||||
MagicNotesSnapshot,
|
||||
@@ -16,7 +18,30 @@ import type {
|
||||
import { MagicNotesWorkspace } from './MagicNotesWorkspace'
|
||||
|
||||
vi.mock('./MagicNoteEditor', () => ({
|
||||
MagicNoteEditor: () => <div data-testid="magic-note-editor" />
|
||||
MagicNoteEditor: ({
|
||||
onChange,
|
||||
onParagraphCommit
|
||||
}: {
|
||||
onChange: (content: MagicNoteDetail['entries'][number]['content']) => void
|
||||
onParagraphCommit?: (
|
||||
content: MagicNoteDetail['entries'][number]['content']
|
||||
) => void
|
||||
}) => (
|
||||
<button
|
||||
data-testid="magic-note-editor"
|
||||
onClick={() => {
|
||||
const content = {
|
||||
version: 1 as const,
|
||||
ops: [{ insert: '新的句子\n' }]
|
||||
}
|
||||
onChange(content)
|
||||
onParagraphCommit?.(content)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
模拟输入并回车
|
||||
</button>
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('./MagicNoteContent', () => ({
|
||||
@@ -29,10 +54,10 @@ const noteTodoId = '00000000-0000-4000-8000-000000000603'
|
||||
const manualTodoId = '00000000-0000-4000-8000-000000000604'
|
||||
const secondNoteId = '00000000-0000-4000-8000-000000000608'
|
||||
const thirdNoteId = '00000000-0000-4000-8000-000000000609'
|
||||
const createdEntryId = '00000000-0000-4000-8000-000000000613'
|
||||
|
||||
const detail: MagicNoteDetail = {
|
||||
id: noteId,
|
||||
projectId: '00000000-0000-4000-8000-000000000101',
|
||||
title: '发布笔记',
|
||||
preview: '整理发布清单',
|
||||
entryCount: 1,
|
||||
@@ -66,7 +91,6 @@ const detail: MagicNoteDetail = {
|
||||
|
||||
const noteTodo: MagicTodoItem = {
|
||||
id: noteTodoId,
|
||||
projectId: detail.projectId,
|
||||
noteId,
|
||||
noteTitle: detail.title,
|
||||
entryId,
|
||||
@@ -83,8 +107,11 @@ const noteTodo: MagicTodoItem = {
|
||||
|
||||
const manualTodo: MagicTodoItem = {
|
||||
id: manualTodoId,
|
||||
projectId: detail.projectId,
|
||||
source: 'manual',
|
||||
noteId: secondNoteId,
|
||||
noteTitle: '演示笔记',
|
||||
entryId: '00000000-0000-4000-8000-000000000610',
|
||||
sourceIndex: 0,
|
||||
source: 'note',
|
||||
title: '准备演示',
|
||||
instructions: '确认演示环境和样例数据。',
|
||||
completed: false,
|
||||
@@ -110,7 +137,6 @@ const summaryFromDetail = (
|
||||
note: MagicNoteDetail
|
||||
): MagicNotesSnapshot['notes'][number] => ({
|
||||
id: note.id,
|
||||
projectId: note.projectId,
|
||||
title: note.title,
|
||||
preview: note.preview,
|
||||
entryCount: note.entryCount,
|
||||
@@ -124,31 +150,80 @@ const list = vi.fn<() => Promise<MagicNotesSnapshot>>()
|
||||
const get = vi.fn<(noteId: string) => Promise<MagicNoteDetail>>()
|
||||
const listTodos = vi.fn<() => Promise<MagicTodosSnapshot>>()
|
||||
const remove = vi.fn<DesktopApi['magicNotes']['remove']>()
|
||||
const createTodo = vi.fn<DesktopApi['magicNotes']['createTodo']>()
|
||||
const updateTodo = vi.fn<DesktopApi['magicNotes']['updateTodo']>()
|
||||
const removeTodo = vi.fn<DesktopApi['magicNotes']['removeTodo']>()
|
||||
const createEntry = vi.fn<DesktopApi['magicNotes']['createEntry']>()
|
||||
const analyze = vi.fn<DesktopApi['magicNotes']['analyze']>()
|
||||
const analyzeTodo = vi.fn<DesktopApi['magicNotes']['analyzeTodo']>()
|
||||
const analyzeDraft = vi.fn<DesktopApi['magicNotes']['analyzeDraft']>()
|
||||
const getApplicationSettings = vi.fn<() => Promise<ApplicationSettings>>(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate'
|
||||
}))
|
||||
const onNotify = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
getApplicationSettings.mockResolvedValue({
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate'
|
||||
})
|
||||
list.mockResolvedValue({ notes: [detail] })
|
||||
get.mockResolvedValue(detail)
|
||||
listTodos.mockResolvedValue({ todos: [noteTodo, manualTodo] })
|
||||
remove.mockResolvedValue()
|
||||
createTodo.mockResolvedValue({
|
||||
...manualTodo,
|
||||
id: '00000000-0000-4000-8000-000000000606',
|
||||
title: '新增手动待办',
|
||||
instructions: '新增说明'
|
||||
const createdDetail: MagicNoteDetail = {
|
||||
...detail,
|
||||
revision: detail.revision + 1,
|
||||
entryCount: 2,
|
||||
entries: [
|
||||
...detail.entries,
|
||||
{
|
||||
...detail.entries[0]!,
|
||||
id: createdEntryId,
|
||||
content: {
|
||||
version: 1,
|
||||
ops: [{ insert: '新的句子\n' }]
|
||||
},
|
||||
plainText: '新的句子',
|
||||
comments: [],
|
||||
analyzedAt: undefined,
|
||||
revision: 0,
|
||||
createdAt: '2026-08-01T00:05:00.000Z',
|
||||
updatedAt: '2026-08-01T00:05:00.000Z'
|
||||
}
|
||||
]
|
||||
}
|
||||
createEntry.mockResolvedValue(createdDetail)
|
||||
analyze.mockResolvedValue({
|
||||
...createdDetail,
|
||||
entries: createdDetail.entries.map((entry) =>
|
||||
entry.id === createdEntryId
|
||||
? {
|
||||
...entry,
|
||||
comments: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000614',
|
||||
kind: 'suggestion',
|
||||
content: '保存后的自动评论。'
|
||||
}
|
||||
],
|
||||
analyzedAt: '2026-08-01T00:06:00.000Z',
|
||||
revision: 1
|
||||
}
|
||||
: entry
|
||||
)
|
||||
})
|
||||
analyzeDraft.mockResolvedValue({
|
||||
id: '00000000-0000-4000-8000-000000000611',
|
||||
comments: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000612',
|
||||
kind: 'summary',
|
||||
content: '这是最新的草稿评论。'
|
||||
}
|
||||
],
|
||||
analyzedAt: '2026-08-01T00:05:00.000Z'
|
||||
})
|
||||
updateTodo.mockImplementation(async (input) => ({
|
||||
...(input.todoId === noteTodo.id ? noteTodo : manualTodo),
|
||||
...input,
|
||||
revision:
|
||||
(input.todoId === noteTodo.id ? noteTodo.revision : manualTodo.revision) +
|
||||
1
|
||||
}))
|
||||
removeTodo.mockResolvedValue()
|
||||
analyzeTodo.mockResolvedValue({
|
||||
...noteTodo,
|
||||
comments: [
|
||||
@@ -169,16 +244,20 @@ beforeEach(() => {
|
||||
get,
|
||||
listTodos,
|
||||
remove,
|
||||
createTodo,
|
||||
updateTodo,
|
||||
removeTodo,
|
||||
analyzeTodo
|
||||
createEntry,
|
||||
analyze,
|
||||
analyzeTodo,
|
||||
analyzeDraft
|
||||
},
|
||||
updates: {
|
||||
getSettings: getApplicationSettings
|
||||
}
|
||||
} as unknown as DesktopApi
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@@ -188,11 +267,7 @@ describe('MagicNotesWorkspace', () => {
|
||||
get.mockRejectedValueOnce(new Error('详情暂时不可用'))
|
||||
|
||||
render(
|
||||
<MagicNotesWorkspace
|
||||
onNotify={onNotify}
|
||||
projectId={detail.projectId}
|
||||
projectName="默认项目"
|
||||
/>
|
||||
<MagicNotesWorkspace onNotify={onNotify} />
|
||||
)
|
||||
|
||||
expect(
|
||||
@@ -212,11 +287,7 @@ describe('MagicNotesWorkspace', () => {
|
||||
|
||||
it('keeps successful data and selection when a refresh fails', async () => {
|
||||
render(
|
||||
<MagicNotesWorkspace
|
||||
onNotify={onNotify}
|
||||
projectId={detail.projectId}
|
||||
projectName="默认项目"
|
||||
/>
|
||||
<MagicNotesWorkspace onNotify={onNotify} />
|
||||
)
|
||||
|
||||
await screen.findByText('记录正文')
|
||||
@@ -246,13 +317,9 @@ describe('MagicNotesWorkspace', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('aggregates note and manual todos without AI-created todo actions', async () => {
|
||||
it('shows note-backed todos with the title above its source', async () => {
|
||||
render(
|
||||
<MagicNotesWorkspace
|
||||
onNotify={onNotify}
|
||||
projectId={detail.projectId}
|
||||
projectName="默认项目"
|
||||
/>
|
||||
<MagicNotesWorkspace onNotify={onNotify} />
|
||||
)
|
||||
|
||||
expect(await screen.findByText('先核对发布材料。')).toBeInTheDocument()
|
||||
@@ -266,27 +333,69 @@ describe('MagicNotesWorkspace', () => {
|
||||
).toHaveClass('page-tabs--segmented')
|
||||
expect(await screen.findAllByText('核对发布材料')).toHaveLength(2)
|
||||
expect(screen.getByText('准备演示')).toBeInTheDocument()
|
||||
expect(screen.getByText('笔记:发布笔记')).toBeInTheDocument()
|
||||
const todoTitle = screen.getByRole('heading', {
|
||||
name: '核对发布材料'
|
||||
})
|
||||
const todoSource = todoTitle.parentElement!.querySelector('span')!
|
||||
expect(
|
||||
todoTitle.compareDocumentPosition(todoSource) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy()
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '标记为已完成' })
|
||||
expect(screen.getByLabelText('未完成')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: '打开原笔记修改' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps history editing contained and uses standard delete buttons', async () => {
|
||||
const { container } = render(
|
||||
<MagicNotesWorkspace onNotify={onNotify} />
|
||||
)
|
||||
|
||||
await screen.findByText('记录正文')
|
||||
const deleteNote = screen.getByRole('button', {
|
||||
name: '删除笔记'
|
||||
})
|
||||
const deleteEntry = screen.getByRole('button', {
|
||||
name: '删除记录'
|
||||
})
|
||||
expect(deleteNote).toHaveClass('danger-button', 'danger-button--quiet')
|
||||
expect(deleteNote).toHaveTextContent('删除笔记')
|
||||
expect(deleteEntry).toHaveClass('danger-button', 'danger-button--quiet')
|
||||
expect(deleteEntry).toHaveTextContent('删除记录')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑' }))
|
||||
await waitFor(() =>
|
||||
expect(updateTodo).toHaveBeenCalledWith({
|
||||
todoId: noteTodo.id,
|
||||
completed: true,
|
||||
expectedRevision: noteTodo.revision
|
||||
})
|
||||
expect(
|
||||
container.querySelector(
|
||||
'.magic-note-entry__editor > [data-testid="magic-note-editor"]'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
)
|
||||
expect(
|
||||
container.querySelector(
|
||||
'.magic-note-entry__editor-actions'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除记录' }))
|
||||
expect(
|
||||
screen.getByText('删除这条记录?此操作不可撤销。')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
container.querySelector('.magic-note-entry__editor')
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑' }))
|
||||
expect(
|
||||
screen.queryByText('删除这条记录?此操作不可撤销。')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('can hide and restore the AI comments pane', async () => {
|
||||
render(
|
||||
<MagicNotesWorkspace
|
||||
onNotify={onNotify}
|
||||
projectId={detail.projectId}
|
||||
projectName="默认项目"
|
||||
/>
|
||||
<MagicNotesWorkspace onNotify={onNotify} />
|
||||
)
|
||||
|
||||
const pane = await screen.findByLabelText('AI 评论')
|
||||
@@ -323,11 +432,7 @@ describe('MagicNotesWorkspace', () => {
|
||||
})
|
||||
|
||||
render(
|
||||
<MagicNotesWorkspace
|
||||
onNotify={onNotify}
|
||||
projectId={detail.projectId}
|
||||
projectName="默认项目"
|
||||
/>
|
||||
<MagicNotesWorkspace onNotify={onNotify} />
|
||||
)
|
||||
|
||||
await screen.findByText('记录正文')
|
||||
@@ -348,11 +453,7 @@ describe('MagicNotesWorkspace', () => {
|
||||
})
|
||||
|
||||
render(
|
||||
<MagicNotesWorkspace
|
||||
onNotify={onNotify}
|
||||
projectId={detail.projectId}
|
||||
projectName="默认项目"
|
||||
/>
|
||||
<MagicNotesWorkspace onNotify={onNotify} />
|
||||
)
|
||||
|
||||
await screen.findByText('记录正文')
|
||||
@@ -388,53 +489,30 @@ describe('MagicNotesWorkspace', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('creates a manual todo with a dedicated title and details form', async () => {
|
||||
it('groups note-backed todos in a directory view', async () => {
|
||||
render(
|
||||
<MagicNotesWorkspace
|
||||
onNotify={onNotify}
|
||||
projectId={detail.projectId}
|
||||
projectName="默认项目"
|
||||
/>
|
||||
<MagicNotesWorkspace onNotify={onNotify} />
|
||||
)
|
||||
|
||||
await screen.findByText('记录正文')
|
||||
fireEvent.click(screen.getByRole('tab', { name: '待办' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建待办' }))
|
||||
expect(createTodo).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建待办' }))
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('请输入待办标题')
|
||||
expect(onNotify).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('待办标题'), {
|
||||
target: { value: '新增手动待办' }
|
||||
})
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText('说明'), {
|
||||
target: { value: '新增说明' }
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建待办' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(createTodo).toHaveBeenCalledWith({
|
||||
projectId: detail.projectId,
|
||||
title: '新增手动待办',
|
||||
instructions: '新增说明'
|
||||
})
|
||||
)
|
||||
expect(onNotify).toHaveBeenCalledWith({
|
||||
tone: 'success',
|
||||
message: '待办已创建'
|
||||
})
|
||||
expect(screen.queryByText('待办已创建')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '新建待办' })
|
||||
).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '目录视图' }))
|
||||
expect(screen.getByText('发布笔记')).toBeInTheDocument()
|
||||
expect(screen.getByText('演示笔记')).toBeInTheDocument()
|
||||
expect(screen.getByText('准备演示')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reuses the AI comments pane for selected todos', async () => {
|
||||
getApplicationSettings.mockResolvedValue({
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'after-save-manual'
|
||||
})
|
||||
render(
|
||||
<MagicNotesWorkspace
|
||||
onNotify={onNotify}
|
||||
projectId={detail.projectId}
|
||||
projectName="默认项目"
|
||||
/>
|
||||
<MagicNotesWorkspace onNotify={onNotify} />
|
||||
)
|
||||
|
||||
await screen.findByText('记录正文')
|
||||
@@ -455,11 +533,7 @@ describe('MagicNotesWorkspace', () => {
|
||||
]
|
||||
})
|
||||
render(
|
||||
<MagicNotesWorkspace
|
||||
onNotify={onNotify}
|
||||
projectId={detail.projectId}
|
||||
projectName="默认项目"
|
||||
/>
|
||||
<MagicNotesWorkspace onNotify={onNotify} />
|
||||
)
|
||||
|
||||
await screen.findByText('记录正文')
|
||||
@@ -482,32 +556,55 @@ describe('MagicNotesWorkspace', () => {
|
||||
expect(screen.getAllByText('核对发布材料')).not.toHaveLength(0)
|
||||
})
|
||||
|
||||
it('clears delete confirmation before selecting the next todo', async () => {
|
||||
it('automatically comments on a newly saved record in auto mode', async () => {
|
||||
getApplicationSettings.mockResolvedValue({
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'after-save-auto'
|
||||
})
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
|
||||
await screen.findByText('记录正文')
|
||||
fireEvent.click(screen.getByText('模拟输入并回车'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存记录' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(createEntry).toHaveBeenCalledWith({
|
||||
noteId,
|
||||
content: {
|
||||
version: 1,
|
||||
ops: [{ insert: '新的句子\n' }]
|
||||
}
|
||||
})
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(analyze).toHaveBeenCalledWith(createdEntryId)
|
||||
)
|
||||
expect(
|
||||
await screen.findByText('保存后的自动评论。')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('comments on an unsaved draft five seconds after Enter', async () => {
|
||||
render(
|
||||
<MagicNotesWorkspace
|
||||
onNotify={onNotify}
|
||||
projectId={detail.projectId}
|
||||
projectName="默认项目"
|
||||
/>
|
||||
<MagicNotesWorkspace onNotify={onNotify} />
|
||||
)
|
||||
|
||||
await screen.findByText('记录正文')
|
||||
fireEvent.click(screen.getByRole('tab', { name: '待办' }))
|
||||
fireEvent.click(screen.getByText('准备演示').closest('button')!)
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除待办' }))
|
||||
expect(
|
||||
screen.getByText('删除“准备演示”?此操作不可撤销。')
|
||||
).toBeInTheDocument()
|
||||
listTodos.mockResolvedValue({ todos: [noteTodo] })
|
||||
fireEvent.click(
|
||||
screen.getAllByRole('button', { name: '删除待办' })[1]!
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(removeTodo).toHaveBeenCalledWith(manualTodo.id)
|
||||
)
|
||||
expect(
|
||||
screen.queryByText('删除“核对发布材料”?此操作不可撤销。')
|
||||
).not.toBeInTheDocument()
|
||||
vi.useFakeTimers()
|
||||
fireEvent.click(screen.getByText('模拟输入并回车'))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(4_999)
|
||||
})
|
||||
expect(analyzeDraft).not.toHaveBeenCalled()
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
})
|
||||
expect(analyzeDraft).toHaveBeenCalledWith({
|
||||
version: 1,
|
||||
ops: [{ insert: '新的句子\n' }]
|
||||
})
|
||||
expect(screen.getByText('这是最新的草稿评论。')).toBeInTheDocument()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
CircleAlert,
|
||||
Database,
|
||||
FlaskConical,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { builtinMcpServers } from '../../shared/builtin-mcp-servers'
|
||||
import { builtinModelTools } from '../../shared/builtin-model-tools'
|
||||
import { builtinModelToolGroups } from '../../shared/builtin-model-tools'
|
||||
import type {
|
||||
CapabilityDiagnosticReport,
|
||||
CapabilityAssignments,
|
||||
@@ -98,6 +99,9 @@ export function McpSettingsSection(): React.JSX.Element {
|
||||
const [testResults, setTestResults] = useState<
|
||||
Record<string, McpServerTestResult>
|
||||
>({})
|
||||
const [expandedItemIds, setExpandedItemIds] = useState<Set<string>>(
|
||||
() => new Set()
|
||||
)
|
||||
const [diagnostics, setDiagnostics] = useState<
|
||||
Partial<Record<ComputerCapabilityId, CapabilityDiagnosticReport>>
|
||||
>({})
|
||||
@@ -111,6 +115,17 @@ export function McpSettingsSection(): React.JSX.Element {
|
||||
undefined
|
||||
)
|
||||
const editorOpen = Boolean(editor)
|
||||
const toggleItem = (itemId: string): void => {
|
||||
setExpandedItemIds((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(itemId)) {
|
||||
next.delete(itemId)
|
||||
} else {
|
||||
next.add(itemId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void window.goodbuddy.capabilities
|
||||
@@ -236,6 +251,11 @@ export function McpSettingsSection(): React.JSX.Element {
|
||||
...current,
|
||||
[server.id]: result
|
||||
}))
|
||||
setExpandedItemIds((current) => {
|
||||
const next = new Set(current)
|
||||
next.add(`custom:${server.id}`)
|
||||
return next
|
||||
})
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : 'MCP 连接测试失败'
|
||||
@@ -320,8 +340,8 @@ export function McpSettingsSection(): React.JSX.Element {
|
||||
|
||||
<p className="settings-notice">
|
||||
自定义 MCP 当前仅用于直连模型,新建时默认分配给直连模型,并仅在 Execute
|
||||
模式加载。内置共享 MCP 当前仅有知识库搜索,可供直连模型、OpenCode 和
|
||||
Continue 使用。Runtime 自有 MCP 配置不在此处管理。
|
||||
模式加载。内置共享 MCP 提供知识库与全局笔记只读搜索,可供直连模型、
|
||||
OpenCode 和 Continue 使用。Runtime 自有 MCP 配置不在此处管理。
|
||||
</p>
|
||||
<p className="settings-notice">
|
||||
内置工具由 GoodBuddy 提供,不属于 MCP Server。自定义 MCP Server
|
||||
@@ -577,35 +597,80 @@ export function McpSettingsSection(): React.JSX.Element {
|
||||
<div className="mcp-subsection-heading">
|
||||
<div>
|
||||
<Database size={15} />
|
||||
<strong id="builtin-mcp-heading">GoodBuddy 内置 MCP</strong>
|
||||
<span className="mcp-subsection-heading__title">
|
||||
<strong id="builtin-mcp-heading">GoodBuddy 内置 MCP</strong>
|
||||
<small>可用于:模型、OpenCode、Continue</small>
|
||||
</span>
|
||||
</div>
|
||||
<small>{builtinMcpServers.length} 个</small>
|
||||
</div>
|
||||
<p className="settings-notice">
|
||||
内置 MCP 由 GoodBuddy 在主进程按当前对话签发短期权限,不公开服务地址或凭据。
|
||||
</p>
|
||||
<div className="capability-list capability-list--tools">
|
||||
{builtinMcpServers.map((server) => (
|
||||
<article className="capability-card" key={server.id}>
|
||||
<div className="capability-card__header">
|
||||
<div>
|
||||
<strong>{server.name}</strong>
|
||||
<small>只读 · 按对话授权</small>
|
||||
</div>
|
||||
<span className="builtin-tool-badge">内置 MCP</span>
|
||||
</div>
|
||||
<p>{server.description}</p>
|
||||
<code>{server.tools.join('、')}</code>
|
||||
<div className="runtime-assignments">
|
||||
<small>可用于:</small>
|
||||
<span>
|
||||
{server.assignments
|
||||
.map((target) => runtimeLabels[target])
|
||||
.join('、')}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
<div className="mcp-server-list">
|
||||
{builtinMcpServers.map((server) => {
|
||||
const expansionId = `builtin:${server.id}`
|
||||
const expanded = expandedItemIds.has(expansionId)
|
||||
const panelId = `mcp-server-tools-${server.id}`
|
||||
return (
|
||||
<article className="mcp-server-card" key={server.id}>
|
||||
<button
|
||||
aria-controls={panelId}
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${expanded ? '收起' : '展开'}服务器 ${server.name}`}
|
||||
className="mcp-server-card__toggle"
|
||||
onClick={() => toggleItem(expansionId)}
|
||||
type="button"
|
||||
>
|
||||
<div>
|
||||
<strong>{server.name}</strong>
|
||||
<small>
|
||||
内置 MCP Server · 只读 · 按对话授权
|
||||
</small>
|
||||
</div>
|
||||
<span className="mcp-server-card__summary">
|
||||
{server.tools.length} 个工具
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={
|
||||
expanded
|
||||
? 'mcp-server-card__chevron mcp-server-card__chevron--expanded'
|
||||
: 'mcp-server-card__chevron'
|
||||
}
|
||||
size={15}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mcp-server-card__body" id={panelId}>
|
||||
<p>{server.description}</p>
|
||||
<section
|
||||
aria-label={`${server.name} 工具`}
|
||||
className="mcp-server-tools"
|
||||
>
|
||||
<div className="mcp-server-tools__heading">
|
||||
<strong>工具</strong>
|
||||
<small>{server.tools.length} 个</small>
|
||||
</div>
|
||||
<ul>
|
||||
{server.tools.map((tool) => (
|
||||
<li key={tool.name}>
|
||||
<div>
|
||||
<code>{tool.name}</code>
|
||||
<span className="builtin-tool-badge">
|
||||
只读
|
||||
</span>
|
||||
</div>
|
||||
<p>{tool.description}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -615,25 +680,73 @@ export function McpSettingsSection(): React.JSX.Element {
|
||||
<Wrench size={15} />
|
||||
<strong>直连模型内置工具</strong>
|
||||
</div>
|
||||
<small>{builtinModelTools.length} 个</small>
|
||||
<small>{builtinModelToolGroups.length} 组</small>
|
||||
</div>
|
||||
<div className="capability-list capability-list--tools">
|
||||
{builtinModelTools.map((tool) => (
|
||||
<article className="capability-card" key={tool.name}>
|
||||
<div className="capability-card__header">
|
||||
<div>
|
||||
<strong>{tool.displayName}</strong>
|
||||
<small>
|
||||
GoodBuddy 内置 ·{' '}
|
||||
{tool.access === 'write' ? '写入工具' : '只读工具'}
|
||||
</small>
|
||||
</div>
|
||||
<span className="builtin-tool-badge">直连模型</span>
|
||||
</div>
|
||||
<p>{tool.description}</p>
|
||||
<code>{tool.name}</code>
|
||||
</article>
|
||||
))}
|
||||
<div className="mcp-server-list">
|
||||
{builtinModelToolGroups.map((group) => {
|
||||
const expansionId = `model-tools:${group.id}`
|
||||
const expanded = expandedItemIds.has(expansionId)
|
||||
const panelId = `model-tool-group-${group.id}`
|
||||
return (
|
||||
<article className="mcp-server-card" key={group.id}>
|
||||
<button
|
||||
aria-controls={panelId}
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${expanded ? '收起' : '展开'}工具组 ${group.name}`}
|
||||
className="mcp-server-card__toggle"
|
||||
onClick={() => toggleItem(expansionId)}
|
||||
type="button"
|
||||
>
|
||||
<div>
|
||||
<strong>{group.name}</strong>
|
||||
<small>GoodBuddy 直连模型内置能力</small>
|
||||
</div>
|
||||
<span className="mcp-server-card__summary">
|
||||
{group.tools.length} 个工具
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={
|
||||
expanded
|
||||
? 'mcp-server-card__chevron mcp-server-card__chevron--expanded'
|
||||
: 'mcp-server-card__chevron'
|
||||
}
|
||||
size={15}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mcp-server-card__body" id={panelId}>
|
||||
<p>{group.description}</p>
|
||||
<section
|
||||
aria-label={`${group.name} 工具`}
|
||||
className="mcp-server-tools"
|
||||
>
|
||||
<div className="mcp-server-tools__heading">
|
||||
<strong>工具</strong>
|
||||
<small>{group.tools.length} 个</small>
|
||||
</div>
|
||||
<ul>
|
||||
{group.tools.map((tool) => (
|
||||
<li key={tool.name}>
|
||||
<div>
|
||||
<span className="mcp-server-tool__identity">
|
||||
<strong>{tool.displayName}</strong>
|
||||
<code>{tool.name}</code>
|
||||
</span>
|
||||
<span className="builtin-tool-badge">
|
||||
{tool.access === 'write' ? '写入' : '只读'}
|
||||
</span>
|
||||
</div>
|
||||
<p>{tool.description}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -859,17 +972,41 @@ export function McpSettingsSection(): React.JSX.Element {
|
||||
)}
|
||||
{snapshot?.mcpServers.map((server) => {
|
||||
const result = testResults[server.id]
|
||||
const expansionId = `custom:${server.id}`
|
||||
const expanded = expandedItemIds.has(expansionId)
|
||||
const panelId = `mcp-custom-server-${server.id}`
|
||||
return (
|
||||
<article className="capability-card" key={server.id}>
|
||||
<div className="capability-card__header">
|
||||
<div>
|
||||
<strong>{server.name}</strong>
|
||||
<small>
|
||||
{server.transport.toUpperCase()} ·{' '}
|
||||
{server.enabled ? '已启用' : '已停用'}
|
||||
{server.secretConfigured ? ' · 已加密令牌' : ''}
|
||||
</small>
|
||||
</div>
|
||||
<article className="mcp-server-card" key={server.id}>
|
||||
<div className="mcp-server-card__header">
|
||||
<button
|
||||
aria-controls={panelId}
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${expanded ? '收起' : '展开'}服务器 ${server.name}`}
|
||||
className="mcp-server-card__toggle"
|
||||
onClick={() => toggleItem(expansionId)}
|
||||
type="button"
|
||||
>
|
||||
<div>
|
||||
<strong>{server.name}</strong>
|
||||
<small>
|
||||
{server.transport.toUpperCase()} MCP Server ·{' '}
|
||||
{server.enabled ? '已启用' : '已停用'}
|
||||
{server.secretConfigured ? ' · 已加密令牌' : ''}
|
||||
</small>
|
||||
</div>
|
||||
<span className="mcp-server-card__summary">
|
||||
{result ? `${result.toolCount} 个工具` : '工具未检测'}
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={
|
||||
expanded
|
||||
? 'mcp-server-card__chevron mcp-server-card__chevron--expanded'
|
||||
: 'mcp-server-card__chevron'
|
||||
}
|
||||
size={15}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
<div className="capability-card__actions">
|
||||
<button
|
||||
aria-label={`测试 ${server.name}`}
|
||||
@@ -911,30 +1048,61 @@ export function McpSettingsSection(): React.JSX.Element {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{server.description && <p>{server.description}</p>}
|
||||
<code>
|
||||
{server.transport === 'stdio'
|
||||
? [server.command, ...server.args].join(' ')
|
||||
: server.url}
|
||||
</code>
|
||||
<div className="runtime-assignments">
|
||||
<small>已分配:</small>
|
||||
<span>
|
||||
{server.assignments
|
||||
.map((target) => runtimeLabels[target])
|
||||
.join('、') || '无'}
|
||||
</span>
|
||||
</div>
|
||||
{result && (
|
||||
<p className="mcp-test-result">
|
||||
连接成功
|
||||
{result.serverName ? `:${result.serverName}` : ''}
|
||||
{result.serverVersion ? ` ${result.serverVersion}` : ''},共{' '}
|
||||
{result.toolCount} 个工具
|
||||
{result.tools.length > 0
|
||||
? `(${result.tools.map((tool) => tool.name).join('、')})`
|
||||
: ''}
|
||||
</p>
|
||||
{expanded && (
|
||||
<div className="mcp-server-card__body" id={panelId}>
|
||||
{server.description && <p>{server.description}</p>}
|
||||
<code>
|
||||
{server.transport === 'stdio'
|
||||
? [server.command, ...server.args].join(' ')
|
||||
: server.url}
|
||||
</code>
|
||||
<div className="runtime-assignments">
|
||||
<small>已分配:</small>
|
||||
<span>
|
||||
{server.assignments
|
||||
.map((target) => runtimeLabels[target])
|
||||
.join('、') || '无'}
|
||||
</span>
|
||||
</div>
|
||||
{result ? (
|
||||
<section
|
||||
aria-label={`${server.name} 工具`}
|
||||
className="mcp-server-tools"
|
||||
>
|
||||
<div className="mcp-server-tools__heading">
|
||||
<strong>
|
||||
{result.serverName || server.name}
|
||||
{result.serverVersion
|
||||
? ` ${result.serverVersion}`
|
||||
: ''}
|
||||
</strong>
|
||||
<small>{result.toolCount} 个工具</small>
|
||||
</div>
|
||||
{result.tools.length > 0 ? (
|
||||
<ul>
|
||||
{result.tools.map((tool) => (
|
||||
<li key={tool.name}>
|
||||
<div>
|
||||
<code>{tool.name}</code>
|
||||
</div>
|
||||
{tool.description && (
|
||||
<p>{tool.description}</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="settings-empty">
|
||||
服务器未公开可用工具。
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<p className="settings-empty">
|
||||
点击“测试”连接服务器并读取其工具列表。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
|
||||
import type {
|
||||
ApplicationSettings,
|
||||
MagicNoteCommentMode
|
||||
} from '../../shared/application-settings-contracts'
|
||||
import { SegmentedControl } from './WorkspacePrimitives'
|
||||
|
||||
type PlatformFeaturesSettingsSectionProps = {
|
||||
onMagicNotesEnabledChange: (enabled: boolean) => void
|
||||
@@ -62,6 +66,26 @@ export function PlatformFeaturesSettingsSection({
|
||||
}
|
||||
}
|
||||
|
||||
const changeCommentMode = async (
|
||||
magicNoteCommentMode: MagicNoteCommentMode
|
||||
): Promise<void> => {
|
||||
const updates = window.goodbuddy.updates
|
||||
if (!updates || !settings) {
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
setSettings(
|
||||
await updates.updateSettings({ magicNoteCommentMode })
|
||||
)
|
||||
} catch {
|
||||
setError('保存 AI 评论方式失败,请重试')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="platform-features-heading"
|
||||
@@ -95,6 +119,23 @@ export function PlatformFeaturesSettingsSection({
|
||||
/>
|
||||
<span>显示魔法笔记入口</span>
|
||||
</label>
|
||||
<div className="platform-feature-option">
|
||||
<span>AI 评论方式</span>
|
||||
<SegmentedControl
|
||||
ariaLabel="魔法笔记 AI 评论方式"
|
||||
disabled={!settings || saving}
|
||||
onChange={(value) => void changeCommentMode(value)}
|
||||
options={[
|
||||
{ value: 'immediate', label: '即时' },
|
||||
{ value: 'after-save-auto', label: '保存后自动' },
|
||||
{ value: 'after-save-manual', label: '保存后手动' }
|
||||
]}
|
||||
value={settings?.magicNoteCommentMode ?? 'immediate'}
|
||||
/>
|
||||
<small>
|
||||
即时模式会在按回车并停止输入 5 秒后评论未保存草稿;自动模式在保存后评论;手动模式仅在点击 AI 分析后评论。
|
||||
</small>
|
||||
</div>
|
||||
</article>
|
||||
{error && (
|
||||
<p className="settings-warning" role="alert">
|
||||
|
||||
@@ -14,13 +14,14 @@ import type {
|
||||
RuntimeSettings
|
||||
} from '../../shared/contracts'
|
||||
import type { CapabilitySnapshot } from '../../shared/capability-contracts'
|
||||
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
|
||||
import type {
|
||||
EmbeddingDiagnosticResult,
|
||||
EmbeddingIndexStatus,
|
||||
EmbeddingSettingsSnapshot
|
||||
} from '../../shared/embedding-contracts'
|
||||
import { builtinMcpServers } from '../../shared/builtin-mcp-servers'
|
||||
import { builtinModelTools } from '../../shared/builtin-model-tools'
|
||||
import { builtinModelToolGroups } from '../../shared/builtin-model-tools'
|
||||
import { SettingsPanel } from './SettingsPanel'
|
||||
|
||||
const modelProfileId = '00000000-0000-4000-8000-000000000001'
|
||||
@@ -325,9 +326,10 @@ const onEmbeddingStatus = vi.fn(
|
||||
}
|
||||
}
|
||||
)
|
||||
let applicationSettings = {
|
||||
let applicationSettings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: true,
|
||||
magicNotesEnabled: false
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate'
|
||||
}
|
||||
const getApplicationSettings = vi.fn(async () => ({
|
||||
...applicationSettings
|
||||
@@ -347,7 +349,8 @@ describe('SettingsPanel runtime files', () => {
|
||||
vi.clearAllMocks()
|
||||
applicationSettings = {
|
||||
checkUpdatesOnStartup: true,
|
||||
magicNotesEnabled: false
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate'
|
||||
}
|
||||
embeddingStatusListeners.splice(0)
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
@@ -462,6 +465,15 @@ describe('SettingsPanel runtime files', () => {
|
||||
})
|
||||
)
|
||||
expect(onMagicNotesEnabledChange).toHaveBeenCalledWith(true)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '保存后自动' })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(updateApplicationSettings).toHaveBeenCalledWith({
|
||||
magicNoteCommentMode: 'after-save-auto'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps page navigation beside an independently scrollable panel', () => {
|
||||
@@ -764,7 +776,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
screen.getByText(/自定义 Continue 可执行文件将以当前用户权限运行/)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/Ask 仅可调用当前授权的知识库搜索/)
|
||||
screen.getByText(/Ask 仅可调用知识库与全局笔记的只读搜索/)
|
||||
).toBeInTheDocument()
|
||||
fireEvent.click(within(field).getByRole('button', { name: '清除' }))
|
||||
expect(input).toHaveValue('')
|
||||
@@ -1823,8 +1835,8 @@ describe('SettingsPanel runtime files', () => {
|
||||
screen.getByText(/自定义 MCP 当前仅用于直连模型/)
|
||||
).toHaveTextContent('新建时默认分配给直连模型')
|
||||
expect(
|
||||
screen.getByText(/内置共享 MCP 当前仅有知识库搜索/)
|
||||
).toHaveTextContent('直连模型、OpenCode 和 Continue')
|
||||
screen.getByText(/内置共享 MCP 提供知识库与全局笔记只读搜索/)
|
||||
).toHaveTextContent(/直连模型、\s*OpenCode 和 Continue/u)
|
||||
expect(
|
||||
screen.getByText(/Runtime 自有 MCP 配置不在此处管理/)
|
||||
).toBeInTheDocument()
|
||||
@@ -1873,22 +1885,57 @@ describe('SettingsPanel runtime files', () => {
|
||||
await waitFor(() =>
|
||||
expect(removeBrowserProfile).toHaveBeenCalledWith(browserProfileId)
|
||||
)
|
||||
expect(
|
||||
await screen.findByText('读取工作区文本')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('列出工作区目录')).toBeInTheDocument()
|
||||
expect(screen.getByText('写入工作区文本')).toBeInTheDocument()
|
||||
expect(await screen.findByText('文件系统操作')).toBeInTheDocument()
|
||||
expect(screen.getByText('浏览器操作')).toBeInTheDocument()
|
||||
expect(screen.queryByText('读取工作区文本')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('知识库 MCP')).toBeInTheDocument()
|
||||
expect(screen.getByText('knowledge_search')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('内置 MCP')).toHaveLength(
|
||||
builtinMcpServers.length
|
||||
expect(screen.queryByText('knowledge_search')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('note_search')).not.toBeInTheDocument()
|
||||
const knowledgeServerToggle = screen.getByRole('button', {
|
||||
name: '展开服务器 知识库 MCP'
|
||||
})
|
||||
expect(knowledgeServerToggle).toHaveAttribute('aria-expanded', 'false')
|
||||
fireEvent.click(knowledgeServerToggle)
|
||||
expect(knowledgeServerToggle).toHaveAttribute('aria-expanded', 'true')
|
||||
const knowledgeTools = screen.getByRole('region', {
|
||||
name: '知识库 MCP 工具'
|
||||
})
|
||||
expect(knowledgeTools).toContainElement(
|
||||
screen.getByText('knowledge_search')
|
||||
)
|
||||
expect(within(knowledgeTools).queryByText(/可用于:/u))
|
||||
.not.toBeInTheDocument()
|
||||
const noteServerToggle = screen.getByRole('button', {
|
||||
name: '展开服务器 笔记 MCP'
|
||||
})
|
||||
fireEvent.click(noteServerToggle)
|
||||
expect(
|
||||
screen.getByRole('region', { name: '笔记 MCP 工具' })
|
||||
).toContainElement(screen.getByText('note_search'))
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: /服务器 .* MCP/u })
|
||||
).toHaveLength(builtinMcpServers.length)
|
||||
expect(
|
||||
screen.getByText('可用于:模型、OpenCode、Continue')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/不公开服务地址或凭据/)
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getAllByText('直连模型')).toHaveLength(
|
||||
builtinModelTools.length
|
||||
)
|
||||
const filesystemToggle = screen.getByRole('button', {
|
||||
name: '展开工具组 文件系统操作'
|
||||
})
|
||||
const browserToggle = screen.getByRole('button', {
|
||||
name: '展开工具组 浏览器操作'
|
||||
})
|
||||
fireEvent.click(filesystemToggle)
|
||||
expect(screen.getByText('读取工作区文本')).toBeInTheDocument()
|
||||
expect(screen.getByText('列出工作区目录')).toBeInTheDocument()
|
||||
expect(screen.getByText('写入工作区文本')).toBeInTheDocument()
|
||||
fireEvent.click(browserToggle)
|
||||
expect(screen.getByText('浏览器导航')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: /工具组/u })
|
||||
).toHaveLength(builtinModelToolGroups.length)
|
||||
expect(
|
||||
await screen.findByText('尚未配置 MCP Server')
|
||||
).toBeInTheDocument()
|
||||
@@ -2040,6 +2087,62 @@ describe('SettingsPanel runtime files', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows custom MCP tools under their expandable server after testing', async () => {
|
||||
getCapabilitySnapshot.mockResolvedValueOnce({
|
||||
...capabilitySnapshot,
|
||||
mcpServers: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000302',
|
||||
name: '团队工具服务',
|
||||
description: '公司内部工具',
|
||||
enabled: true,
|
||||
assignments: ['model'],
|
||||
secretConfigured: false,
|
||||
transport: 'http',
|
||||
url: 'https://mcp.example.com/mcp'
|
||||
}
|
||||
]
|
||||
})
|
||||
vi.mocked(
|
||||
window.goodbuddy.capabilities.testMcpServer
|
||||
).mockResolvedValueOnce({
|
||||
serverName: 'Team MCP',
|
||||
serverVersion: '1.2.0',
|
||||
toolCount: 1,
|
||||
tools: [
|
||||
{
|
||||
name: 'team_search',
|
||||
description: '搜索团队资料'
|
||||
}
|
||||
]
|
||||
})
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'MCP' }))
|
||||
const serverToggle = await screen.findByRole('button', {
|
||||
name: '展开服务器 团队工具服务'
|
||||
})
|
||||
expect(serverToggle).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.queryByText('team_search')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '测试 团队工具服务' })
|
||||
)
|
||||
expect(await screen.findByText('team_search')).toBeInTheDocument()
|
||||
expect(serverToggle).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(
|
||||
screen.getByRole('region', { name: '团队工具服务 工具' })
|
||||
).toHaveTextContent('搜索团队资料')
|
||||
})
|
||||
|
||||
it('creates, updates, and removes roles with system prompts', async () => {
|
||||
const onExpertsChanged = vi.fn()
|
||||
render(
|
||||
|
||||
@@ -1310,7 +1310,7 @@ export function SettingsPanel({
|
||||
原生模型、插件或 MCP 配置时,才切换到 Runtime 自有配置。
|
||||
</div>
|
||||
<div className="runtime-note">
|
||||
对话时可选择 Ask 或 Execute。Ask 仅可调用当前授权的知识库搜索;Execute
|
||||
对话时可选择 Ask 或 Execute。Ask 仅可调用知识库与全局笔记的只读搜索;Execute
|
||||
可调用已启用工具,调用过程会记录到活动。
|
||||
</div>
|
||||
{detectionSummary(detection?.opencode)}
|
||||
@@ -1509,7 +1509,7 @@ export function SettingsPanel({
|
||||
原生模型、规则或 MCP 配置时,才切换到 Runtime 自有配置。
|
||||
</div>
|
||||
<div className="runtime-note">
|
||||
对话时可选择 Ask 或 Execute。Ask 仅可调用当前授权的知识库搜索;Execute
|
||||
对话时可选择 Ask 或 Execute。Ask 仅可调用知识库与全局笔记的只读搜索;Execute
|
||||
可调用已启用工具,调用过程会记录到活动。
|
||||
</div>
|
||||
{detectionSummary(detection?.continue)}
|
||||
@@ -1780,12 +1780,13 @@ export function SettingsPanel({
|
||||
)}
|
||||
<button
|
||||
aria-label={`删除模型连接 ${profile.name}`}
|
||||
className="icon-button"
|
||||
className="danger-button danger-button--quiet"
|
||||
disabled={modelProfiles.length <= 1}
|
||||
onClick={() => removeModelProfile(profile.id)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
删除连接
|
||||
</button>
|
||||
</div>
|
||||
<label className="field">
|
||||
|
||||
@@ -21,7 +21,9 @@ describe('UpdateSettingsSection', () => {
|
||||
>(async (input) => ({
|
||||
checkUpdatesOnStartup:
|
||||
input.checkUpdatesOnStartup ?? true,
|
||||
magicNotesEnabled: input.magicNotesEnabled ?? true
|
||||
magicNotesEnabled: input.magicNotesEnabled ?? true,
|
||||
magicNoteCommentMode:
|
||||
input.magicNoteCommentMode ?? 'immediate'
|
||||
}))
|
||||
const check = vi.fn<
|
||||
NonNullable<DesktopApi['updates']>['check']
|
||||
@@ -59,7 +61,8 @@ describe('UpdateSettingsSection', () => {
|
||||
updates: {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
magicNotesEnabled: true
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate'
|
||||
})),
|
||||
updateSettings,
|
||||
check,
|
||||
@@ -107,11 +110,13 @@ describe('UpdateSettingsSection', () => {
|
||||
updates: {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
magicNotesEnabled: true
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate'
|
||||
})),
|
||||
updateSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
magicNotesEnabled: true
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate'
|
||||
})),
|
||||
check: vi.fn(async () => {
|
||||
throw new Error(
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('WorkspacePrimitives', () => {
|
||||
|
||||
it('keeps shared controls keyboard and pointer accessible at narrow widths', () => {
|
||||
expect(stylesheet).toMatch(
|
||||
/\.window-control\s*>\s*svg,\s*\.icon-button\s*>\s*svg\s*\{[^}]*pointer-events:\s*none;/u
|
||||
/button\s*>\s*svg,\s*button\s*>\s*svg\s+\*\s*\{[^}]*pointer-events:\s*none;/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/button:focus-visible,\s*input:focus-visible,\s*select:focus-visible,\s*textarea:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--accent\);/u
|
||||
|
||||
@@ -221,11 +221,13 @@ export function PageTabs<T extends string>({
|
||||
|
||||
export function SegmentedControl<T extends string>({
|
||||
ariaLabel,
|
||||
disabled = false,
|
||||
onChange,
|
||||
options,
|
||||
value
|
||||
}: {
|
||||
ariaLabel: string
|
||||
disabled?: boolean
|
||||
onChange: (value: T) => void
|
||||
options: readonly SegmentedOption<T>[]
|
||||
value: T
|
||||
@@ -244,6 +246,7 @@ export function SegmentedControl<T extends string>({
|
||||
? 'segmented-control__option segmented-control__option--active'
|
||||
: 'segmented-control__option'
|
||||
}
|
||||
disabled={disabled}
|
||||
key={option.value}
|
||||
onClick={() => onChange(option.value)}
|
||||
onKeyDown={(event) => {
|
||||
|
||||
+251
-17
@@ -196,7 +196,7 @@
|
||||
|
||||
.magic-notes-create > div,
|
||||
.magic-note-entry header > div,
|
||||
.magic-note-entry__editor > div {
|
||||
.magic-note-entry__editor-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
@@ -216,7 +216,8 @@
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-items: start;
|
||||
padding: var(--space-3);
|
||||
min-height: 48px;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
@@ -265,6 +266,41 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.magic-todo-directory {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.magic-todo-directory__heading {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
padding: var(--space-2) var(--space-1) var(--space-1);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
gap: var(--space-1);
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.magic-todo-directory__heading strong {
|
||||
overflow: hidden;
|
||||
color: var(--text-secondary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.magic-todo-directory__heading > span {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.magic-todo-directory__items {
|
||||
display: grid;
|
||||
padding-left: var(--space-3);
|
||||
border-left: 1px solid var(--border-default);
|
||||
margin-left: 7px;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.magic-note-list-item {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
@@ -336,7 +372,7 @@
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
align-items: start;
|
||||
padding-bottom: var(--space-4);
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
gap: var(--space-3);
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
@@ -349,7 +385,6 @@
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
@@ -367,8 +402,8 @@
|
||||
.magic-todo-detail h2 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 20px;
|
||||
line-height: 1.4;
|
||||
font-size: 18px;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -503,6 +538,8 @@
|
||||
|
||||
.magic-note-composer,
|
||||
.magic-note-entry {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-raised);
|
||||
@@ -553,6 +590,7 @@
|
||||
}
|
||||
|
||||
.magic-note-entry__editor {
|
||||
min-width: 0;
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
@@ -571,10 +609,17 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.magic-note-entry__editor > div:last-child {
|
||||
.magic-note-entry__editor-actions {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.magic-note-editor {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.magic-note-editor__toolbar.ql-toolbar.ql-snow {
|
||||
padding: var(--space-2);
|
||||
border: 0;
|
||||
@@ -681,12 +726,6 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.magic-notes-page .danger-ghost {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.magic-notes-page .danger-solid {
|
||||
min-height: var(--control-height);
|
||||
padding: 0 13px;
|
||||
@@ -1347,8 +1386,8 @@ textarea:focus-visible {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.window-control > svg,
|
||||
.icon-button > svg {
|
||||
button > svg,
|
||||
button > svg * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -3982,10 +4021,29 @@ textarea:focus-visible {
|
||||
overflow: hidden;
|
||||
align-items: stretch;
|
||||
grid-template-columns: 190px minmax(0, 760px);
|
||||
justify-content: center;
|
||||
justify-content: start;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.platform-feature-option {
|
||||
display: grid;
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
color: var(--text-secondary);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.platform-feature-option > span {
|
||||
font-size: var(--font-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.platform-feature-option > small {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.settings-page .settings-tabs {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
@@ -4798,6 +4856,163 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.mcp-server-list {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.mcp-server-card {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.mcp-server-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: var(--space-3);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.mcp-server-card__toggle {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 52px;
|
||||
padding: var(--space-3);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
gap: var(--space-3);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mcp-server-card__header > .mcp-server-card__toggle {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.mcp-server-card__toggle:hover {
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.mcp-server-card__toggle > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.mcp-server-card__toggle strong,
|
||||
.mcp-server-tools__heading strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-body);
|
||||
}
|
||||
|
||||
.mcp-server-card__toggle small,
|
||||
.mcp-server-card__summary,
|
||||
.mcp-server-tools__heading small {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.mcp-server-card__summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.mcp-server-card__chevron {
|
||||
transition: transform var(--motion-normal) ease-out;
|
||||
}
|
||||
|
||||
.mcp-server-card__chevron--expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.mcp-server-card__body {
|
||||
display: grid;
|
||||
padding: var(--space-3);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.mcp-server-card__body > p,
|
||||
.mcp-server-tools li > p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.mcp-server-card__body > code {
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-control);
|
||||
overflow: hidden;
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mcp-server-tools {
|
||||
display: grid;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-subtle);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.mcp-server-tools__heading,
|
||||
.mcp-server-tools li > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.mcp-server-tools ul {
|
||||
display: grid;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
gap: var(--space-2);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.mcp-server-tools li {
|
||||
display: grid;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-raised);
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.mcp-server-tools code {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-caption);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.mcp-server-tool__identity {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.mcp-server-tool__identity strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.computer-capability-risk {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -5048,10 +5263,23 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
@media (max-width: 720px) {
|
||||
.capability-diagnostic,
|
||||
.browser-profile-create,
|
||||
.browser-profile-row {
|
||||
.browser-profile-row,
|
||||
.mcp-server-card__header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mcp-server-card__header {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.mcp-server-card__header > .capability-card__actions {
|
||||
padding: 0 var(--space-3) var(--space-3);
|
||||
}
|
||||
|
||||
.mcp-server-card__toggle {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.mcp-subsection-heading,
|
||||
@@ -5070,6 +5298,12 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.mcp-subsection-heading__title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.mcp-subsection-heading strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-body);
|
||||
|
||||
Reference in New Issue
Block a user