fix: simplify note capture controls and entry points

Remove duplicate quick-note shortcuts, redundant scope text and visible content labels. Right-align note action groups while retaining accessible input names and validation feedback.

Release note: Simplify the notes panel and align save and source actions to the right. Open notes from the workbar or conversation capture actions.
This commit is contained in:
mesalogo
2026-09-28 11:45:52 +08:00
parent 61590395ef
commit 013e72f69e
12 changed files with 33 additions and 27 deletions
+1 -1
View File
@@ -772,7 +772,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
### 13.7 魔法笔记
会话联动已接入:右侧提供可关闭的单实例笔记 Tab,左下角“快速笔记”打开同一面板,侧栏收起时由聊天页头提供入口。助手消息和会话标题菜单可打开文字采集预览;保存记录在面板和完整工作区显示独立来源条。草稿由 App 保留,关闭、替换及禁用前确认。具体交互由[魔法笔记会话联动界面设计](./docs/features/magic-notes/ui-design.md)维护。真实 Electron 验收已通过,全仓验证未全绿,见[验证进度](./docs/features/magic-notes/progress.md)。下列规则描述独立工作区。
会话联动已接入:右侧提供可关闭的单实例笔记 Tab,通过工作栏“+ → 笔记”打开;左下角和聊天页头不再提供重复的“快速笔记”入口。助手消息和会话标题菜单可打开文字采集预览;保存记录在面板和完整工作区显示独立来源条。草稿由 App 保留,关闭、替换及禁用前确认。具体交互由[魔法笔记会话联动界面设计](./docs/features/magic-notes/ui-design.md)维护。真实 Electron 验收已通过,全仓验证未全绿,见[验证进度](./docs/features/magic-notes/progress.md)。下列规则描述独立工作区。
- 笔记总览与待办使用一个普通按钮切换,放在页头“新建笔记”按钮左边。按钮显示目标“切换到待办”或“切换到笔记”及对应图标,按内容宽度显示,不拉伸;窄窗口操作组可换行到标题下方,保留控件高度。
- 切换按钮不使用页签语义,切换后焦点回到同一个按钮;内容 section 持续提供当前列表的 `aria-label`。切换沿用草稿保护入口,待办状态仍使用独立的 `SegmentedControl`。
+1 -1
View File
@@ -26,7 +26,7 @@ FR-6:成功写入触发现有通知刷新视图;面板重新挂载或工作
## 启用与评论
FR-1~FR-4:遵循魔法笔记应用的启用状态。禁用时隐藏会话采集、快速笔记及工作栏目录入口,并移除笔记 Tab;禁用前处理草稿。外部设置变更要求禁用时,选择继续编辑会尝试恢复启用设置,失败显示设置错误。应用未常驻不影响快速入口或工作栏可用性。设置继续由应用中心管理。
FR-1~FR-4:遵循魔法笔记应用的启用状态。禁用时隐藏会话采集及工作栏目录入口,并移除笔记 Tab;禁用前处理草稿。外部设置变更要求禁用时,选择继续编辑会尝试恢复启用设置,失败显示设置错误。应用未常驻不影响会话采集或工作栏可用性。设置继续由应用中心管理。
会话采集和快速追加本身不调用模型。保存后沿用现有 AI 评论模式;只有已有设置要求保存后自动评论时才触发,失败不撤销保存,也不自动重新追加。
+1 -1
View File
@@ -15,7 +15,7 @@
| FR-1 | 右侧助手工作栏增加“笔记”应用,支持搜索、打开已有笔记、新建笔记、查看记录摘要、快速追加文字,以及打开对应完整工作区。 |
| FR-2 | 助手消息操作栏提供“加入笔记”,打开可编辑预览,用户选择已有笔记或新建后明确提交。 |
| FR-3 | 会话标题的更多菜单提供“保存当前对话到笔记”,按触发时的会话内容生成预览,保存为目标笔记中的一条记录。 |
| FR-4 | 左侧底部提供“快速笔记”,侧栏收起时由聊天页头提供同名图标入口;打开右侧面板并恢复当前应用会话中最近选择的笔记,首次使用显示列表。此入口不隐式保存当前对话。 |
| FR-4 | 通过右侧工作栏“+ → 笔记”打开面板并恢复当前应用会话中最近选择的笔记,首次使用显示列表,不隐式保存当前对话。左侧底部和聊天页头不再提供重复的“快速笔记”入口。 |
| FR-5 | 从会话保存的记录保留结构化来源,显示会话名称、所属项目(如有)和采集时间,支持返回原会话及单条来源消息。源会话删除后保留笔记内容。 |
| FR-6 | 面板、完整工作区和 Agent 写入使用同一份笔记数据,成功写入后更新已打开视图,同时保护未保存草稿。 |
+5 -5
View File
@@ -9,16 +9,15 @@
| 助手工作栏“+”菜单 | 增加“笔记”,打开或激活单个笔记 Tab。 |
| 助手消息底部,复制旁 | 图标按钮“加入笔记”,进入该消息的采集预览。 |
| 会话标题更多菜单 | “保存当前对话到笔记”,加载完整历史后进入整段采集预览;生成、采集加载或保存期间禁用。 |
| 左侧底部,应用中心与设置操作组上方 | “快速笔记”,打开面板;侧栏收起时在聊天页头显示带同名提示的图标。 |
| 面板笔记标题旁 | “在完整工作区打开”,进入当前笔记;有待保存文字时先处理草稿。 |
左侧主导航中的“魔法笔记”继续打开独立工作区,两处入口通过名称说明不同目的。快速笔记不读取文本选区,不根据选区改变动作。
左侧主导航中的“魔法笔记”继续打开独立工作区。紧凑面板通过工作栏“+ → 笔记”打开,左侧底部和聊天页头不再提供重复的“快速笔记”入口。
## 紧凑面板
面板按单列显示列表或笔记详情,不嵌入完整工作区的三栏布局。搜索位于列表顶部,“新建笔记”在列表之后;笔记行显示标题、置顶状态、两行摘要和更新时间。无笔记时提供新建动作;搜索无结果时保留搜索词并允许创建。
详情顶部提供返回列表、笔记标题、全局范围标识和打开完整工作区。中间以可滚动记录摘要供浏览,每条文字摘要最多显示前 600 个字符,画布显示首页缩略图及页数;记录按钮可在完整工作区定位该记录。底部提供快速追加文字输入区与“加入笔记”按钮,画布编辑、待办管理和 AI 评论阅读仍在完整工作区。
详情顶部提供返回列表、笔记标题和打开完整工作区,不显示“全局”标识。中间以可滚动记录摘要供浏览,每条文字摘要最多显示前 600 个字符,画布显示首页缩略图及页数;记录按钮可在完整工作区定位该记录。底部提供快速追加文字输入区与“加入笔记”按钮,画布编辑、待办管理和 AI 评论阅读仍在完整工作区。
从会话触发时,面板切到采集编辑视图:
@@ -27,18 +26,19 @@
来自:项目 / 会话标题 · 单条回答或当前对话
保存范围:用户与助手正文,不含推理、工具日志和附件文件
待加入内容
[可编辑文字预览]
保存到
[搜索或选择已有笔记]
[新建笔记 → 标题输入]
[取消] [加入笔记]
[取消] [加入笔记]
```
范围说明随单条或整段来源调整。预览初始包含完整待保存文字,可滚动编辑。新建目标标题默认取会话标题前 100 个字符,可修改;已有目标与新建目标使用共享 `SegmentedControl` 切换。采集内容为空或超限、缺少目标、新建标题为空或超限时就地提示,并禁用保存;普通新建允许创建空笔记。
预览和快速追加输入框通过无障碍名称“待加入内容”标识,不再显示重复标签。提交区仅显示校验或保存错误,不显示“全局”。提交与来源跳转按钮组均居右,可在窄面板换行;来源没有项目名时省略项目及分隔符,不补写“全局”。
读取失败显示就地重试按钮;保存失败保留输入并在提交区显示错误,用户可再次点击“加入笔记”。成功通过应用通知反馈,切回目标笔记详情,滚动定位、聚焦并高亮新增记录约 2.4 秒。仅“保存后自动评论”模式发起后续分析,分析失败另行通知,已保存内容保留。
## 来源与可访问性
@@ -44,7 +44,7 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
保存后的记录带有“来自会话”入口。单条回答可以返回对应消息;原会话或消息被删除后,已保存的笔记内容仍保留。每次采集新增一条记录,后续对话修改不会自动同步到笔记。
“快速笔记”入口和工作栏“+ → 笔记”可以打开紧凑面板,在聊天旁搜索笔记标题与正文、浏览摘要、追加文字。切换项目或会话时草稿保留,关闭有未保存内容的面板前会提示;草稿仅在本次应用运行期间保留。画布、待办和完整编辑继续在魔法笔记工作区中使用。
工作栏“+ → 笔记”可以打开紧凑面板,在聊天旁搜索笔记标题与正文、浏览摘要、追加文字。切换项目或会话时草稿保留,关闭有未保存内容的面板前会提示;草稿仅在本次应用运行期间保留。画布、待办和完整编辑继续在魔法笔记工作区中使用。
采集保存文本与 Markdown,不复制推理过程、工具日志或附件图片。生成中的回答暂不可采集,单条记录上限为 500 KiB,超限会提示缩减。采集本身无需调用模型;保存后自动评论仍按已有设置执行。笔记继续属于全局,来源项目只用于帮助回看。
@@ -33,7 +33,7 @@
| --- | --- | --- | --- |
| 本机 Obsidian 接入 | 新增/设置→能力与工具→MCP | 注册仓库发现、指定文件夹、独立测试与保存、开关和 Runtime 分配;搜索、读取、写入、追加、局部修改等。默认关闭;直连模型及受管理本机 OpenCode/Continue 支持,DSH 与远程不支持;Ask 只读、Execute 可写;不是知识库复制或同步 | `55efca3`;`src/main/obsidian/obsidian-service.ts`、`src/shared/obsidian-tools.ts`、[Obsidian 进度](../../features/obsidian/progress.md) |
| 聊天采集到魔法笔记 | 新增/助手消息、会话菜单 | 单条助手正文或完整会话用户/助手正文,预览编辑、追加或原子创建笔记;保留来源回跳,源删除后内容保留;不复制工具日志/附件,单记录 500 KiB,生成中不可采集 | `1094433`;`App.tsx`、`ChatTimeline.tsx`、`magic-notes-contracts.ts`、[笔记进度](../../features/magic-notes/progress.md) |
| 快速笔记与草稿 | 新增/快速笔记、工作栏“+” | 标题/正文搜索、摘要、文字追加;切项目/会话保留草稿,关闭/替换/禁用有确认,重启不恢复;仍是全局笔记,完整画布/待办编辑在工作区;采集本身不调模型,自动评论沿用设置 | `1094433`;`MagicNotesPanel.tsx`、`MagicNoteSource.tsx`、`use-magic-note-draft.tsx` |
| 快速笔记与草稿 | 工作栏“+ → 笔记” | 标题/正文搜索、摘要、文字追加;切项目/会话保留草稿,关闭/替换/禁用有确认,重启不恢复;仍是全局笔记,完整画布/待办编辑在工作区;采集本身不调模型,自动评论沿用设置 | `1094433`;`MagicNotesPanel.tsx`、`MagicNoteSource.tsx`、`use-magic-note-draft.tsx` |
| 来源存储升级 | 兼容性/启动 | schema 48 保存来源元数据,不改写文件化正文;旧客户端不能直接打开升级数据库 | `1094433`;`assistant-database.ts`、笔记技术设计 |
| 本地原生客户端 | 新增/聊天输入区 | Continue/OpenCode 工作栏终端、DS 系统浏览器 Web;沿用项目与模型,独立历史;DS 可复用、重开、停止,关闭网页不停止服务。远程协调入口未接通;Continue/DS 需标准 Node,托管准备可能联网;Continue/OpenCode 原生 Ask 暂无 MCP | `f81f342`;`native-client-coordinator.ts`、`native-terminal-client.ts`、`native-dsh-web-client.ts`、[客户端方案](../../features/assistant-workbar/runtime-native-client-proposal.md) |
| DSH 依赖升级 | 新功能配套/内部版本 | DSH 0.1.7-rc.2、Cordis 4.0.4;不把依赖版本当模型名或稳定版承诺,正文并入原生客户端 | `f81f342`;提交中的 package/锁文件 |
+10 -4
View File
@@ -14056,11 +14056,13 @@ describe("App", () => {
const preview = await screen.findByLabelText('待加入内容')
expect(preview).toHaveValue(messages.map(message => `## ${message.role === 'user' ? '用户' : '助手'}\n\n${message.content}`).join('\n\n'))
expect(screen.getAllByRole('tab', { name: '笔记' })).toHaveLength(1)
fireEvent.click(screen.getByRole('button', { name: '快速笔记' }))
expect(screen.queryByRole('button', { name: '快速笔记' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '打开工作栏应用' }))
fireEvent.click(screen.getByRole('button', { name: /^笔记搜索/ }))
expect(screen.getAllByRole('tab', { name: '笔记' })).toHaveLength(1)
fireEvent.change(preview, { target: { value: 'Frozen edited capture' } })
fireEvent.click(screen.getByRole('button', { name: /收起工作栏|助手工作栏/ }))
fireEvent.click(screen.getByRole('button', { name: '快速笔记' }))
fireEvent.click(screen.getByRole('button', { name: '切换助手工作栏' }))
expect(await screen.findByLabelText('待加入内容')).toHaveValue('Frozen edited capture')
fireEvent.click(screen.getByRole('button', { name: /新建对话/ }))
expect(await screen.findByLabelText('待加入内容')).toHaveValue('Frozen edited capture')
@@ -14150,7 +14152,9 @@ describe("App", () => {
render(<App />)
await screen.findByText('Source history 101')
expect(screen.queryByText('Source history 1')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '快速笔记' }))
fireEvent.click(screen.getByRole('button', { name: '切换助手工作栏' }))
fireEvent.click(screen.getByRole('button', { name: '打开工作栏应用' }))
fireEvent.click(screen.getByRole('button', { name: /^笔记搜索/ }))
const notesPanel = within(await screen.findByRole('region', { name: '快速笔记' }))
fireEvent.click(await notesPanel.findByRole('button', { name: /Source note/ }))
fireEvent.click(await notesPanel.findByRole('button', { name: '查看来源消息' }))
@@ -14172,7 +14176,9 @@ describe("App", () => {
it('guards disabling Magic Notes with an unsaved quick draft before saving application settings', async () => {
await api.updates!.updateSettings({ magicNotesEnabled: true })
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: '快速笔记' }))
fireEvent.click(await screen.findByRole('button', { name: '切换助手工作栏' }))
fireEvent.click(screen.getByRole('button', { name: '打开工作栏应用' }))
fireEvent.click(screen.getByRole('button', { name: /^笔记搜索/ }))
fireEvent.click(await screen.findByRole('button', { name: '新建笔记' }))
fireEvent.change(screen.getByLabelText('笔记标题'), { target: { value: 'Unsaved quick note' } })
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
-2
View File
@@ -9340,7 +9340,6 @@ function App(): React.JSX.Element {
) : null}
</div>
{magicNotesEnabled && <button type="button" className="nav-item" title={t('magicNotes:capture.quick')} onClick={openQuickNotes}><FileText size={17} aria-hidden="true" /><span>{t('magicNotes:capture.quick')}</span></button>}
<div className="sidebar-footer sidebar-footer--applications">
<button
className="nav-item"
@@ -9502,7 +9501,6 @@ function App(): React.JSX.Element {
</>
)}
<div className="topbar__actions">
{magicNotesEnabled && !sidebarOpen && <button type="button" className="icon-button" aria-label={t('magicNotes:capture.quick')} title={t('magicNotes:capture.quick')} onClick={openQuickNotes}><FileText size={17} aria-hidden="true" /></button>}
<button
aria-controls="assistant-sidebar"
aria-expanded={assistantSidebarOpen}
+1 -1
View File
@@ -19,7 +19,7 @@ export function MagicNoteSource({ source, onOpen }: { source: Source; onOpen?: O
}
return <aside className="magic-note-source" aria-label={t('capture.source')}>
<span>{t('capture.source')}: {source.conversationTitle}</span>
<span>{source.projectName ?? t('capture.global')} · <time dateTime={source.capturedAt}>{new Date(source.capturedAt).toLocaleString(i18n.language)}</time></span>
<span>{source.projectName && `${source.projectName} · `}<time dateTime={source.capturedAt}>{new Date(source.capturedAt).toLocaleString(i18n.language)}</time></span>
{onOpen && <div className="magic-note-panel__actions">
<button className="secondary-button" type="button" disabled={missing || busy} onClick={() => void open()}>{t(missing ? 'capture.missingConversation' : 'capture.openSource')}</button>
{source.kind === 'message' && <button className="secondary-button" type="button" disabled={missing || busy} onClick={() => void open(source.messageIds[0])}>{t('capture.openMessage')}</button>}
@@ -51,9 +51,13 @@ describe('compact notes', () => {
render(<Harness />)
fireEvent.click(screen.getByText('Capture'))
await screen.findByDisplayValue('Captured text')
expect(screen.getByRole('textbox', { name: 'Text to add' })).toBeVisible()
expect(screen.queryByText('Text to add')).not.toBeInTheDocument()
expect(screen.queryByText('Global')).not.toBeInTheDocument()
expect(create).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'New note' }))
expect(screen.getByLabelText('Note title')).toHaveValue('Discussion A')
expect(document.getElementById('compact-note-validation')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Add to note' }))
await waitFor(() => {
expect(create).toHaveBeenCalledWith({ title: 'Discussion A', content: capturedNoteContent('Captured text'), source })
+7 -9
View File
@@ -6,7 +6,7 @@ import type { MagicNoteCommentMode } from '../../shared/application-settings-con
import { MagicNoteSource, type OpenMagicNoteSource } from './MagicNoteSource'
import type { useMagicNoteDraft } from './use-magic-note-draft'
import type { AppNotificationInput } from './notifications'
import { ScopeBadge, SegmentedControl } from './WorkspacePrimitives'
import { SegmentedControl } from './WorkspacePrimitives'
import './magic-notes-panel.css'
const MagicCanvasThumbnail = lazy(async () => ({ default: (await import('./MagicCanvasThumbnail')).MagicCanvasThumbnail }))
@@ -149,7 +149,8 @@ export function MagicNotesPanel({ state, active, commentMode, commentFormat = 'c
setSelectedNoteId('')
requestAnimationFrame(() => document.getElementById('compact-note-search')?.focus())
}
const validationId = `compact-note-validation${saveError ? ' compact-note-save-error' : ''}`
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 list = (
<>
<label htmlFor="compact-note-search">{t('capture.search')}</label>
@@ -182,7 +183,6 @@ export function MagicNotesPanel({ state, active, commentMode, commentFormat = 'c
}}>
<header className="magic-note-panel__header">
<strong>{draft?.source ? t('capture.add') : currentDetail?.title ?? t('page.title')}</strong>
<ScopeBadge scope={{ kind: 'global' }} />
{(!draft || quickDraft) && currentDetail && (
<div className="magic-note-panel__actions">
<button className="icon-button" type="button" title={t('capture.back')}
@@ -202,12 +202,11 @@ export function MagicNotesPanel({ state, active, commentMode, commentFormat = 'c
{draft && !quickDraft ? (
<>
{draft.source && <div className="magic-note-source">
<span>{draft.source.projectName ?? t('capture.global')} / {draft.source.conversationTitle}</span>
<span>{draft.source.projectName && `${draft.source.projectName} / `}{draft.source.conversationTitle}</span>
<p>{t(draft.source.kind === 'message' ? 'capture.messageScope' : 'capture.conversationScope')}</p>
{draft.incomplete && <p>{t('capture.incomplete')}</p>}
</div>}
<label htmlFor="compact-note-text">{t('capture.content')}</label>
<textarea ref={preview} id="compact-note-text" value={draft.text} disabled={saving}
<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 })} />
<h3>{t('capture.target')}</h3>
@@ -259,14 +258,13 @@ export function MagicNotesPanel({ state, active, commentMode, commentFormat = 'c
{(draft || currentDetail) && (
<footer className="magic-note-panel__footer">
{(!draft || quickDraft) && currentDetail && <>
<label htmlFor="compact-note-append">{t('capture.content')}</label>
<textarea id="compact-note-append" value={draft?.text ?? ''} disabled={saving}
<textarea id="compact-note-append" aria-label={t('capture.content')} value={draft?.text ?? ''} disabled={saving}
aria-describedby={validationId} aria-invalid={tooLong || undefined}
onChange={event => setDraft(draft ? { ...draft, text: event.target.value } : {
text: event.target.value, initialText: '', title: '', initialTitle: '', targetId: currentDetail.id, newNote: false
})} />
</>}
<p id="compact-note-validation">{tooLong ? t('capture.tooLong') : draft && !valid ? t('capture.required') : t('capture.global')}</p>
{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}
+1 -1
View File
@@ -6,7 +6,7 @@
.magic-note-panel__scroll > * { flex-shrink: 0; }
.magic-note-panel h3 { margin: 0; font-size: var(--font-section-title); }
.magic-note-panel__scroll > .segmented-control { flex: 0 0 auto; }
.magic-note-panel__actions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.magic-note-panel__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); }
.magic-note-panel input, .magic-note-panel textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: var(--space-2); color: var(--text-primary); background: var(--surface-raised); border: 1px solid var(--border-control); border-radius: var(--radius-control); font: inherit; }
.magic-note-panel textarea { min-height: 180px; resize: vertical; }
.magic-note-panel__list { display: flex; flex-direction: column; gap: var(--space-2); }