fix: keep note capture actions compact and right-aligned

Remove initial generic validation instructions, show size errors beside their fields, and expose capture scope through contextual help. Keep save errors beside actions where space allows and preserve content-sized Save buttons in narrow note workspaces.

Release note: Note capture now uses a compact action bar with right-aligned buttons, on-demand scope help, and field-specific validation. Narrow workspaces no longer stretch the Save button.
This commit is contained in:
mesalogo
2026-09-28 21:30:03 +08:00
parent 50e8fde64c
commit 393a07ccdf
11 changed files with 299 additions and 24 deletions
+24
View File
@@ -55,6 +55,13 @@ describe('compact notes', () => {
expect(screen.queryByText('Text to add')).not.toBeInTheDocument()
expect(screen.queryByText('Global')).not.toBeInTheDocument()
expect(create).not.toHaveBeenCalled()
expect(document.querySelector('.magic-note-panel__footer p')).toBeNull()
expect(screen.getByRole('button', { name: 'Add to note' })).toBeDisabled()
expect(screen.queryByText(/excluding reasoning/)).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'What gets saved' }))
expect(screen.getByRole('tooltip')).toHaveTextContent('excluding reasoning')
fireEvent.keyDown(window, { key: 'Escape' })
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'New note' }))
expect(screen.getByLabelText('Note title')).toHaveValue('Discussion A')
expect(document.getElementById('compact-note-validation')).not.toBeInTheDocument()
@@ -162,11 +169,28 @@ describe('compact notes', () => {
expect(preview).toHaveValue(oversized)
expect(screen.getByRole('button', { name: 'Add to note' })).toBeDisabled()
expect(screen.getByText(/Each entry supports up to 500 KB/)).toBeVisible()
expect(preview).toHaveAccessibleDescription(/Each entry supports up to 500 KB/)
expect(document.querySelector('.magic-note-panel__footer p')).toBeNull()
fireEvent.change(preview, { target: { value: 'Within capacity' } })
expect(screen.getByRole('button', { name: 'Add to note' })).toBeEnabled()
expect(create).not.toHaveBeenCalled()
})
it('keeps new-note validation beside the title without initial footer instructions', async () => {
render(<Harness />)
fireEvent.click(await screen.findByRole('button', { name: 'New note' }))
const title = screen.getByRole('textbox', { name: 'Note title' })
expect(document.querySelector('.magic-note-panel__footer p')).toBeNull()
expect(screen.getByRole('button', { name: 'Create note' })).toBeDisabled()
fireEvent.change(title, { target: { value: 'x'.repeat(101) } })
expect(title).toHaveAccessibleDescription('Use a title of up to 100 characters.')
expect(title).toHaveAttribute('aria-invalid', 'true')
expect(document.querySelector('.magic-note-panel__footer p')).toBeNull()
fireEvent.change(title, { target: { value: 'Valid title' } })
expect(screen.queryByText('Use a title of up to 100 characters.')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Create note' })).toBeEnabled()
})
it('preserves text when the target disappears and requires a new selection', async () => {
render(<Harness />)
fireEvent.click(screen.getByText('Capture'))
+22 -13
View File
@@ -7,6 +7,7 @@ import { MagicNoteSource, type OpenMagicNoteSource } from './MagicNoteSource'
import type { useMagicNoteDraft } from './use-magic-note-draft'
import type { AppNotificationInput } from './notifications'
import { SegmentedControl } from './WorkspacePrimitives'
import { InlineHelp } from './InlineHelp'
import './magic-notes-panel.css'
const MagicCanvasThumbnail = lazy(async () => ({ default: (await import('./MagicCanvasThumbnail')).MagicCanvasThumbnail }))
@@ -149,8 +150,8 @@ export function MagicNotesPanel({ state, active, commentMode, commentFormat = 'c
setSelectedNoteId('')
requestAnimationFrame(() => document.getElementById('compact-note-search')?.focus())
}
const validationMessage = tooLong ? t('capture.tooLong') : draft && !valid ? t('capture.required') : undefined
const validationId = [validationMessage && 'compact-note-validation', saveError && 'compact-note-save-error'].filter(Boolean).join(' ') || undefined
const validationId = tooLong ? 'compact-note-validation' : undefined
const titleTooLong = Boolean(draft?.newNote && draft.title.trim().length > 100)
const list = (
<>
<label htmlFor="compact-note-search">{t('capture.search')}</label>
@@ -202,13 +203,18 @@ export function MagicNotesPanel({ state, active, commentMode, commentFormat = 'c
{draft && !quickDraft ? (
<>
{draft.source && <div className="magic-note-source">
<span>{draft.source.projectName && `${draft.source.projectName} / `}{draft.source.conversationTitle}</span>
<p>{t(draft.source.kind === 'message' ? 'capture.messageScope' : 'capture.conversationScope')}</p>
<div className="magic-note-panel__source-heading">
<span>{draft.source.projectName && `${draft.source.projectName} / `}{draft.source.conversationTitle}</span>
<InlineHelp label={t('capture.scopeHelp')}>
{t(draft.source.kind === 'message' ? 'capture.messageScope' : 'capture.conversationScope')}
</InlineHelp>
</div>
{draft.incomplete && <p>{t('capture.incomplete')}</p>}
</div>}
<textarea ref={preview} id="compact-note-text" aria-label={t('capture.content')} value={draft.text} disabled={saving}
aria-describedby={validationId} aria-invalid={tooLong || undefined}
onChange={event => setDraft({ ...draft, text: event.target.value })} />
{tooLong && <p id="compact-note-validation">{t('capture.tooLong')}</p>}
<h3>{t('capture.target')}</h3>
<SegmentedControl ariaLabel={t('capture.target')} disabled={saving}
value={draft.newNote ? 'new' : 'existing'}
@@ -218,8 +224,9 @@ export function MagicNotesPanel({ state, active, commentMode, commentFormat = 'c
<>
<label htmlFor="compact-note-title">{t('capture.title')}</label>
<input ref={title} id="compact-note-title" value={draft.title} disabled={saving}
aria-describedby={validationId} aria-invalid={draft.title.trim().length > 100 || undefined}
aria-describedby={titleTooLong ? 'compact-note-title-error' : undefined} aria-invalid={titleTooLong || undefined}
onChange={event => setDraft({ ...draft, title: event.target.value })} />
{titleTooLong && <p id="compact-note-title-error">{t('capture.titleTooLong')}</p>}
</>
) : (
<>
@@ -264,14 +271,16 @@ export function MagicNotesPanel({ state, active, commentMode, commentFormat = 'c
text: event.target.value, initialText: '', title: '', initialTitle: '', targetId: currentDetail.id, newNote: false
})} />
</>}
{validationMessage && <p id="compact-note-validation">{validationMessage}</p>}
{saveError && <p role="alert" id="compact-note-save-error">{saveError}</p>}
<div className="magic-note-panel__actions">
{draft && <button className="secondary-button" type="button" disabled={saving}
onClick={() => void cancel()}>{t('actions.cancel')}</button>}
<button className="primary-button" type="button" disabled={saving || !valid} onClick={() => void save()}>
{t(saving ? 'capture.saving' : draft?.newNote && !draft.text.trim() && !draft.source ? 'actions.createNote' : 'capture.add')}
</button>
{quickDraft && tooLong && <p id="compact-note-validation">{t('capture.tooLong')}</p>}
<div className="magic-note-panel__submit">
{saveError && <p role="alert" id="compact-note-save-error">{saveError}</p>}
<div className="magic-note-panel__actions">
{draft && <button className="secondary-button" type="button" disabled={saving}
onClick={() => void cancel()}>{t('actions.cancel')}</button>}
<button className="primary-button" type="button" disabled={saving || !valid} onClick={() => void save()}>
{t(saving ? 'capture.saving' : draft?.newNote && !draft.text.trim() && !draft.source ? 'actions.createNote' : 'capture.add')}
</button>
</div>
</div>
</footer>
)}
@@ -13,7 +13,7 @@ export const magicNotes = {
conversationScope: 'Saves all user and assistant text at capture time, including earlier history, excluding reasoning, tool logs, images, and attachment files.',
incomplete: 'This reply is incomplete. The preview includes its available text.', wait: 'Wait for the reply to finish or stop generation before saving.',
tooLong: 'Each entry supports up to 500 KB of text. Shorten the content before saving.',
required: 'Enter text and choose a note, or enter a new note title (up to 100 characters).',
scopeHelp: 'What gets saved', titleTooLong: 'Use a title of up to 100 characters.',
saving: 'Saving...', deletedTarget: 'The target note was deleted. Choose another note; your draft is preserved.',
user: 'User', assistant: 'Assistant', loading: 'Loading the full conversation...'
},
@@ -10,7 +10,7 @@ export const magicNotes = {
conversationScope: '保存范围:触发时完整对话的用户与助手文字正文,不含推理、工具日志、图片和附件文件。',
incomplete: '这条回答尚未完成,预览保留已有正文。', wait: '请等待回答完成或停止生成后再保存。',
tooLong: '每条记录的文字不能超过 500 KB,请缩减内容后保存。',
required: '请输入正文并选择目标笔记,或填写新笔记标题(最多 100 字)。',
scopeHelp: '保存内容说明', titleTooLong: '笔记标题最多 100 字。',
saving: '正在保存…', deletedTarget: '目标笔记已删除,请重新选择;草稿已保留。',
user: '用户', assistant: '助手', loading: '正在读取完整对话…'
},
+5
View File
@@ -22,6 +22,11 @@
.magic-note-panel__preview { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 180px; overflow: auto; }
.magic-note-panel__footer { border-top: 1px solid var(--border-subtle); border-bottom: 0; }
.magic-note-panel__footer p { margin: 0 0 var(--space-2); overflow-wrap: anywhere; }
.magic-note-panel__source-heading { display: flex; align-items: center; gap: var(--space-2); }
.magic-note-panel__source-heading > span { min-width: 0; }
.magic-note-panel__submit { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
.magic-note-panel__submit > p { flex: 1 1 200px; min-width: 0; margin: 0; }
.magic-note-panel__submit > .magic-note-panel__actions { margin-left: auto; }
.assistant-sidebar__body.assistant-sidebar__body--notes { overflow: hidden; padding: 0; display: flex; flex-direction: column; }
.assistant-sidebar__body--notes > .magic-note-panel { flex: 1; }
.magic-note-panel .magic-note-panel__footer textarea { min-height: 80px; max-height: 180px; }
-5
View File
@@ -2467,11 +2467,6 @@
}
@container magic-notes-page (max-width: 720px) {
.magic-note-composer > footer {
align-items: stretch;
flex-direction: column;
}
.magic-note-entry__delete {
align-items: stretch;
flex-direction: column;