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
+1 -1
View File
@@ -6,7 +6,7 @@
FR-2、FR-3:单条采集读取所选助手消息的非空正文;整段采集先加载该会话完整历史,再按消息顺序收录用户和助手的非空正文,保留角色分隔。预览建立后,切换会话或收到新消息不会替换正文及来源标识。生成中的消息不可采集,停止或失败后已有正文可保存,单条预览注明回答未完成。整个会话正在生成时禁用整段采集,提示等待完成或停止生成。
首版按文字保存,保留原始 Markdown 文本、换行和代码文本,不承诺还原聊天渲染效果。推理、工具调用日志、系统提示、附件二进制和图片不进入正文;预览持续说明保存范围。用户可编辑预览,来源仍指向本次采集的会话。读取完整会话失败时不以屏幕上已有消息代替。
首版按文字保存,保留原始 Markdown 文本、换行和代码文本,不承诺还原聊天渲染效果。推理、工具调用日志、系统提示、附件二进制和图片不进入正文;预览来源旁提供保存范围帮助,按需展开。用户可编辑预览,来源仍指向本次采集的会话。读取完整会话失败时不以屏幕上已有消息代替。
FR-5:来源独立于可编辑正文。单条来源包含会话和消息标识;整段来源包含会话标识、此次收录的消息标识及采集时间。预览使用采集时的会话标题和项目名称;保存时主进程校验消息归属,并以当时数据库中的会话标题和项目名称写入来源,无项目时显示全局。若预览期间源会话或消息已删除,保存失败并保留预览。保存完成后,修改或删除源消息不回写笔记正文和来源。
+4 -2
View File
@@ -24,7 +24,7 @@
```text
加入笔记
来自:项目 / 会话标题 · 单条回答或当前对话
保存范围:用户与助手正文,不含推理、工具日志和附件文件
来源旁帮助按钮:按需查看保存范围
[可编辑文字预览]
@@ -35,7 +35,9 @@
[取消] [加入笔记]
```
范围说明随单条或整段来源调整。预览初始包含完整待保存文字,可滚动编辑。新建目标标题默认取会话标题前 100 个字符,可修改;已有目标与新建目标使用共享 `SegmentedControl` 切换。采集内容为空或超限、缺少目标、新建标题为空或超限时就地提示,并禁用保存;普通新建允许创建空笔记。
范围说明随单条或整段来源调整,通过来源旁的 `InlineHelp` 按需查看;回答未完成的警告仍直接显示。预览初始包含完整待保存文字,可滚动编辑。新建目标标题默认取会话标题前 100 个字符,可修改;已有目标与新建目标使用共享 `SegmentedControl` 切换。采集内容为空或超限、缺少目标、新建标题为空或超限时禁用保存;初始不显示通用校验说明,正文和标题超限时在对应字段旁提示。普通新建允许创建空笔记。
底栏默认仅显示右对齐的操作按钮。保存失败时保留草稿,错误与按钮在宽面板中同行,窄面板中错误换行、按钮仍靠右。完整工作区的文字保存按钮在窄窗口中也保持内容宽度并右对齐。
预览和快速追加输入框通过无障碍名称“待加入内容”标识,不再显示重复标签。提交区仅显示校验或保存错误,不显示“全局”。提交与来源跳转按钮组均居右,可在窄面板换行;来源没有项目名时省略项目及分隔符,不补写“全局”。
+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;
+186
View File
@@ -0,0 +1,186 @@
// @vitest-environment node
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { createServer } from 'vite'
import react from '@vitejs/plugin-react'
import { expect, it } from 'vitest'
it('renders compact capture footers and responsive save errors in Electron across locales and themes', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-notes-footer-'))
const server = await createServer({
configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
plugins: [react(), { name: 'notes-footer', configureServer(server) {
server.middlewares.use('/footer.html', async (_request, response) => {
response.setHeader('Content-Type', 'text/html')
response.end(await server.transformIndexHtml('/footer.html',
'<html><body><script type="module" src="/tests/support/magic-notes-footer-regression.tsx"></script></body></html>'))
})
} }],
server: { host: '127.0.0.1', port: 0, watch: null },
optimizeDeps: { entries: ['tests/support/magic-notes-footer-regression.tsx'] }
})
try {
await server.listen()
const driver = join(directory, 'driver.cjs')
await writeFile(driver, `
const { app, BrowserWindow } = require('electron');
const fs = require('node:fs');
const assert = require('node:assert/strict');
const trace = message => fs.appendFileSync(${JSON.stringify(join(directory, 'driver.log'))}, message + '\\n');
trace('Starting Electron');
app.commandLine.appendSwitch('force-device-scale-factor', '1');
// Keep animation-frame measurements running when other test windows cover this one.
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion');
app.setPath('userData', ${JSON.stringify(join(directory, 'profile'))});
app.whenReady().then(async () => {
trace('Electron ready');
const win = new BrowserWindow({ show: true, width: 900, height: 720, useContentSize: true,
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } });
const js = async code => {
let timeout;
try {
return await Promise.race([win.webContents.executeJavaScript(code), new Promise((_, reject) => {
timeout = setTimeout(() => reject(new Error('Renderer timeout: ' + code)), 15000);
})]);
} catch (error) { throw new Error(code + ': ' + error.message); }
finally { clearTimeout(timeout); }
};
const settle = () => js('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
const wait = async code => { for (let i = 0; i < 400; i++) {
if (await js(code)) return;
await new Promise(resolve => setTimeout(resolve, 25));
} throw new Error('Timeout: ' + code); };
const click = async selector => {
const r = await js('document.querySelector(' + JSON.stringify(selector) + ').getBoundingClientRect().toJSON()');
win.focus(); win.webContents.focus();
const x = Math.round(r.x + r.width / 2), y = Math.round(r.y + r.height / 2);
win.webContents.sendInputEvent({ type: 'mouseMove', x, y });
win.webContents.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 });
win.webContents.sendInputEvent({ type: 'mouseUp', x, y, button: 'left', clickCount: 1 });
await settle();
};
const resize = async width => {
win.show(); win.focus();
win.setContentSize(width, 720);
await wait('innerWidth === ' + width); await settle();
};
const measure = () => js(
'(() => {' +
'const footer = document.querySelector(".magic-note-panel__footer"), style = getComputedStyle(footer);' +
'const rect = selector => document.querySelector(selector)?.getBoundingClientRect().toJSON();' +
'return { footer: footer.getBoundingClientRect().toJSON(), actions: rect(".magic-note-panel__footer .magic-note-panel__actions"),' +
'cancel: rect(".magic-note-panel__footer .secondary-button"), save: rect(".magic-note-panel__footer .primary-button"),' +
'error: rect("#compact-note-save-error"), paddingRight: parseFloat(style.paddingRight),' +
'chrome: parseFloat(style.paddingTop) + parseFloat(style.paddingBottom) + parseFloat(style.borderTopWidth),' +
'background: style.backgroundColor, overflow: document.documentElement.scrollWidth > innerWidth }; })()');
const aligned = (m, context) => {
assert(Math.abs(m.save.right - (m.footer.right - m.paddingRight)) <= 1, context + ': right alignment ' + JSON.stringify(m));
assert(Math.abs(m.cancel.y - m.save.y) <= 1, context + ': buttons stay on one row');
assert(m.cancel.right <= m.save.x && m.cancel.x >= m.footer.x, context + ': buttons fit without overlap');
assert(m.save.bottom <= m.footer.bottom && m.footer.bottom <= 720, context + ': footer remains visible');
assert(!m.overflow, context + ': no horizontal overflow');
};
const screenshot = async name => {
if (process.env.GOODBUDDY_NOTES_SCREENSHOT_DIR) {
fs.writeFileSync(require('node:path').join(process.env.GOODBUDDY_NOTES_SCREENSHOT_DIR, name + '.png'), (await win.webContents.capturePage()).toPNG());
}
};
const results = [];
for (const locale of ['en-US', 'zh-CN']) for (const theme of ['light', 'dark']) for (const kind of ['message', 'conversation']) {
const context = locale + '/' + theme + '/' + kind;
trace(context + ': navigating');
await win.loadURL(${JSON.stringify(server.resolvedUrls!.local[0] + 'footer.html')} + '?locale=' + locale + '&theme=' + theme + '&kind=' + kind);
trace(context + ': loaded');
await resize(900);
trace(context + ': resized');
await wait('!!document.querySelector("#compact-note-text") && !document.querySelector(".magic-note-panel__scroll [role=status]")');
trace(context + ': rendered');
await js('document.fonts.ready'); await settle();
trace(context + ': fonts ready');
assert.equal(await js('document.querySelector("#compact-note-text").value'), 'Captured text', context);
assert.equal(await js('document.body.dataset.saveCount'), '0', context);
assert(await js('document.querySelector(".magic-note-panel__footer .primary-button").disabled'), context);
assert.equal(await js('document.querySelector(".magic-note-panel__footer p")'), null, context + ': no initial footer paragraph');
const initial = [];
for (const width of [900, 300]) {
trace(context + ': initial ' + width);
await resize(width);
const m = await measure(); aligned(m, context + '/' + width);
assert.equal(m.footer.width, width, context + ': actual panel width');
assert(Math.abs(m.footer.height - m.chrome - m.actions.height) <= 1, context + ': compact footer ' + JSON.stringify(m));
assert(m.footer.height <= 65, context + ': bounded initial footer height');
assert.equal(await js('document.body.innerText.includes(document.body.dataset.scope)'), false, context + ': scope hidden');
await click('.magic-note-panel__source-heading .inline-help');
await wait('!!document.querySelector("[role=tooltip]")');
assert(await js('document.querySelector("[role=tooltip]").checkVisibility()'), context);
assert.equal(await js('document.querySelector("[role=tooltip]").textContent'), await js('document.body.dataset.scope'), context + ': correct capture scope');
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' });
await wait('!document.querySelector("[role=tooltip]")');
assert.equal(await js('document.body.innerText.includes(document.body.dataset.scope)'), false, context);
assert.equal((await measure()).footer.height, m.footer.height, context + ': help does not expand footer');
initial.push({ width, height: m.footer.height, saveWidth: m.save.width });
await screenshot('magic-notes-footer-' + locale + '-' + theme + '-' + kind + '-initial-' + width);
}
assert(Math.abs(initial[0].saveWidth - initial[1].saveWidth) <= 1, context + ': content-sized button');
await resize(900);
trace(context + ': saving');
await click('.magic-note-panel__scroll .segmented-control button:last-child');
await wait('!!document.querySelector("#compact-note-title")');
await click('.magic-note-panel__footer .primary-button');
await wait('!!document.querySelector("#compact-note-save-error") && !document.querySelector(".magic-note-panel__footer .primary-button").disabled');
assert.equal(await js('document.body.dataset.saveCount'), '1', context + ': real save handler reaches storage');
assert.equal(await js('document.querySelector("#compact-note-save-error").textContent'), 'Storage unavailable. Please try again.', context);
assert.equal(await js('document.querySelector("#compact-note-save-error").getAttribute("role")'), 'alert', context);
assert.equal(await js('document.querySelector("#compact-note-text").value'), 'Captured text', context + ': failed save preserves draft');
const wide = await measure(); aligned(wide, context + '/wide-error');
assert(Math.abs((wide.error.y + wide.error.height / 2) - (wide.actions.y + wide.actions.height / 2)) <= 1, context + ': error and actions share row');
assert(wide.error.right <= wide.actions.x, context + ': error does not overlap actions');
assert.equal(wide.footer.height, initial[0].height, context + ': wide error adds no footer height');
await screenshot('magic-notes-footer-' + locale + '-' + theme + '-' + kind + '-error-900');
await resize(300);
const narrow = await measure(); aligned(narrow, context + '/narrow-error');
trace(context + ': narrow error measured');
assert(narrow.actions.y >= narrow.error.bottom + 7, context + ': error wraps above buttons ' + JSON.stringify(narrow));
assert(narrow.footer.height > wide.footer.height, context + ': wrapped footer grows');
assert(Math.abs(narrow.save.width - wide.save.width) <= 1, context + ': error does not stretch button');
assert(await js('(() => { const e = document.querySelector("#compact-note-save-error"); return e.checkVisibility() && e.scrollWidth <= e.clientWidth && e.scrollHeight <= e.clientHeight; })()'), context + ': error is not clipped');
await screenshot('magic-notes-footer-' + locale + '-' + theme + '-' + kind + '-error-300');
results.push({ locale, theme, kind, initial, wideHeight: wide.footer.height, narrowHeight: narrow.footer.height,
wideErrorY: wide.error.y, wideActionsY: wide.actions.y, narrowErrorBottom: narrow.error.bottom,
narrowActionsY: narrow.actions.y, rightInset: narrow.footer.right - narrow.save.right, background: wide.background });
}
assert.notEqual(results[0].background, results[2].background, 'Real theme styles applied');
fs.writeFileSync(${JSON.stringify(join(directory, 'result.json'))}, JSON.stringify(results));
app.exit(0);
}).catch(error => { trace(error.stack || String(error)); console.error(error); app.exit(1); });
`)
const env = { ...process.env }
delete env.ELECTRON_RUN_AS_NODE
const child = spawn(createRequire(import.meta.url)('electron'), [driver], { env, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', data => { output += data })
child.stderr.on('data', data => { output += data })
let timedOut = false
const timeout = setTimeout(() => { timedOut = true; child.kill() }, 90000)
try {
const { code, signal } = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
child.once('close', (code, signal) => resolve({ code, signal }))
child.once('error', reject)
})
output += await readFile(join(directory, 'driver.log'), 'utf8').catch(() => '')
expect(timedOut, `Electron footer exceeded 90s (exit=${code}, signal=${signal}).\n${output}`).toBe(false)
expect(signal, output).toBeNull()
expect(code, output).toBe(0)
} finally { clearTimeout(timeout) }
const results = JSON.parse(await readFile(join(directory, 'result.json'), 'utf8'))
console.info('Notes footer Electron geometry:', JSON.stringify(results, null, 2))
expect(results).toHaveLength(8)
} finally {
await server.close()
await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
}
}, 120000)
+7 -1
View File
@@ -74,6 +74,8 @@ it('resizes record columns with native Electron input and preserves desktop widt
await open(); await js('document.fonts.ready'); win.focus(); win.webContents.focus();
const editor = '.magic-note-composer .magic-note-editor__content .ql-editor';
const emptyHeight = (await rect(editor)).height;
const saveButton = '.magic-note-composer > footer > .primary-button';
const desktopSaveWidth = (await rect(saveButton)).width;
assert(emptyHeight >= 110 && emptyHeight <= 125, 'Empty composer height: ' + emptyHeight);
assert.equal(await js('document.querySelector("#magic-notes-title").textContent'), 'Layout note');
assert.equal(await js('document.querySelector(".page-header__description")'), null);
@@ -113,6 +115,10 @@ it('resizes record columns with native Electron input and preserves desktop widt
assert(await js('document.documentElement.scrollWidth <= innerWidth'));
const narrowStream = (await rect(stream)).width;
await click('#magic-notes-index-toggle'); assert.equal((await rect(stream)).width, narrowStream);
const narrowSave = await rect(saveButton), narrowFooter = await rect('.magic-note-composer > footer');
const footerPaddingRight = await js('parseFloat(getComputedStyle(document.querySelector(".magic-note-composer > footer")).paddingRight)');
assert(Math.abs(narrowSave.width - desktopSaveWidth) <= 1, 'Narrow Save button retains its content width: ' + JSON.stringify({ desktopSaveWidth, narrowSave, narrowFooter }));
assert(Math.abs(narrowFooter.x + narrowFooter.width - footerPaddingRight - narrowSave.x - narrowSave.width) <= 1, 'Narrow Save button stays right-aligned within footer padding');
await screenshot('magic-notes-narrow');
win.setContentSize(1280, 800); await settle(); await settle();
assert.equal((await rect(index)).width, withoutAi);
@@ -161,7 +167,7 @@ it('resizes record columns with native Electron input and preserves desktop widt
await wait('!!document.querySelector("#magic-library-switch")');
await click('#magic-library-switch');
assert.equal((await rect('#magic-todo-list')).width, todoWidth);
fs.writeFileSync(${JSON.stringify(join(directory, 'result.json'))}, JSON.stringify({ emptyHeight, grownHeight, dragged, withoutAi, constrained, narrowStream, persisted: true }));
fs.writeFileSync(${JSON.stringify(join(directory, 'result.json'))}, JSON.stringify({ emptyHeight, grownHeight, dragged, withoutAi, constrained, narrowStream, desktopSaveWidth, narrowSave, narrowFooter, footerPaddingRight, persisted: true }));
app.exit(0);
}).catch(error => { console.error(error); app.exit(1); });
`)
@@ -0,0 +1,48 @@
import { useLayoutEffect } from 'react'
import { createRoot } from 'react-dom/client'
import type { DesktopApi } from '../../src/shared/contracts'
import type { MagicNoteSource } from '../../src/shared/magic-notes-contracts'
import { MagicNotesPanel } from '../../src/renderer/src/MagicNotesPanel'
import { useMagicNoteDraft } from '../../src/renderer/src/use-magic-note-draft'
import i18n from '../../src/renderer/src/i18n'
import '../../src/renderer/src/styles.css'
const parameters = new URLSearchParams(location.search)
await i18n.changeLanguage(parameters.get('locale') === 'zh-CN' ? 'zh-CN' : 'en-US')
document.documentElement.dataset.theme = parameters.get('theme') === 'dark' ? 'dark' : 'light'
const source: MagicNoteSource = {
kind: parameters.get('kind') === 'conversation' ? 'conversation' : 'message',
conversationId: 'footer-conversation', messageIds: ['footer-message'],
capturedAt: '2026-09-26T00:00:00Z', conversationTitle: 'Footer discussion', projectName: 'Footer project'
}
document.body.dataset.scope = i18n.t(`capture.${source.kind === 'message' ? 'messageScope' : 'conversationScope'}`, { ns: 'magicNotes' })
document.body.dataset.saveCount = '0'
window.goodbuddy = {
magicNotes: {
search: async () => [],
onChanged: () => () => {},
create: async () => {
document.body.dataset.saveCount = String(Number(document.body.dataset.saveCount) + 1)
throw new Error('Storage unavailable. Please try again.')
}
}
} as unknown as DesktopApi
function Harness(): React.JSX.Element {
const state = useMagicNoteDraft()
const { setDraft } = state
useLayoutEffect(() => {
setDraft({ text: 'Captured text', initialText: 'Captured text', title: 'Footer discussion',
initialTitle: 'Footer discussion', targetId: '', newNote: false, source })
}, [setDraft])
return <>
<MagicNotesPanel state={state} active commentMode="after-save-manual" onNotify={() => {}}
onOpenWorkspace={() => {}} onOpenSource={async () => 'opened'} onCancelCapture={() => {}} />
{state.confirmation}
</>
}
const host = document.createElement('div')
host.style.height = '100vh'
document.body.append(host)
createRoot(host).render(<Harness />)