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:
@@ -6,7 +6,7 @@
|
||||
|
||||
FR-2、FR-3:单条采集读取所选助手消息的非空正文;整段采集先加载该会话完整历史,再按消息顺序收录用户和助手的非空正文,保留角色分隔。预览建立后,切换会话或收到新消息不会替换正文及来源标识。生成中的消息不可采集,停止或失败后已有正文可保存,单条预览注明回答未完成。整个会话正在生成时禁用整段采集,提示等待完成或停止生成。
|
||||
|
||||
首版按文字保存,保留原始 Markdown 文本、换行和代码文本,不承诺还原聊天渲染效果。推理、工具调用日志、系统提示、附件二进制和图片不进入正文;预览持续说明保存范围。用户可编辑预览,来源仍指向本次采集的会话。读取完整会话失败时不以屏幕上已有消息代替。
|
||||
首版按文字保存,保留原始 Markdown 文本、换行和代码文本,不承诺还原聊天渲染效果。推理、工具调用日志、系统提示、附件二进制和图片不进入正文;预览来源旁提供保存范围帮助,按需展开。用户可编辑预览,来源仍指向本次采集的会话。读取完整会话失败时不以屏幕上已有消息代替。
|
||||
|
||||
FR-5:来源独立于可编辑正文。单条来源包含会话和消息标识;整段来源包含会话标识、此次收录的消息标识及采集时间。预览使用采集时的会话标题和项目名称;保存时主进程校验消息归属,并以当时数据库中的会话标题和项目名称写入来源,无项目时显示全局。若预览期间源会话或消息已删除,保存失败并保留预览。保存完成后,修改或删除源消息不回写笔记正文和来源。
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
```text
|
||||
加入笔记
|
||||
来自:项目 / 会话标题 · 单条回答或当前对话
|
||||
保存范围:用户与助手正文,不含推理、工具日志和附件文件
|
||||
来源旁帮助按钮:按需查看保存范围
|
||||
|
||||
[可编辑文字预览]
|
||||
|
||||
@@ -35,7 +35,9 @@
|
||||
[取消] [加入笔记]
|
||||
```
|
||||
|
||||
范围说明随单条或整段来源调整。预览初始包含完整待保存文字,可滚动编辑。新建目标标题默认取会话标题前 100 个字符,可修改;已有目标与新建目标使用共享 `SegmentedControl` 切换。采集内容为空或超限、缺少目标、新建标题为空或超限时就地提示,并禁用保存;普通新建允许创建空笔记。
|
||||
范围说明随单条或整段来源调整,通过来源旁的 `InlineHelp` 按需查看;回答未完成的警告仍直接显示。预览初始包含完整待保存文字,可滚动编辑。新建目标标题默认取会话标题前 100 个字符,可修改;已有目标与新建目标使用共享 `SegmentedControl` 切换。采集内容为空或超限、缺少目标、新建标题为空或超限时禁用保存;初始不显示通用校验说明,正文和标题超限时在对应字段旁提示。普通新建允许创建空笔记。
|
||||
|
||||
底栏默认仅显示右对齐的操作按钮。保存失败时保留草稿,错误与按钮在宽面板中同行,窄面板中错误换行、按钮仍靠右。完整工作区的文字保存按钮在窄窗口中也保持内容宽度并右对齐。
|
||||
|
||||
预览和快速追加输入框通过无障碍名称“待加入内容”标识,不再显示重复标签。提交区仅显示校验或保存错误,不显示“全局”。提交与来源跳转按钮组均居右,可在窄面板换行;来源没有项目名时省略项目及分隔符,不补写“全局”。
|
||||
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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: '正在读取完整对话…'
|
||||
},
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
@@ -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 />)
|
||||
Reference in New Issue
Block a user