- {draft &&
}
-
+ {quickDraft && tooLong &&
{t('capture.tooLong')}
}
+
+ {saveError &&
{saveError}
}
+
+ {draft && }
+
+
)}
diff --git a/src/renderer/src/i18n/locales/en-US/magicNotes.ts b/src/renderer/src/i18n/locales/en-US/magicNotes.ts
index 9c95c2eb..64291ec4 100644
--- a/src/renderer/src/i18n/locales/en-US/magicNotes.ts
+++ b/src/renderer/src/i18n/locales/en-US/magicNotes.ts
@@ -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...'
},
diff --git a/src/renderer/src/i18n/locales/zh-CN/magicNotes.ts b/src/renderer/src/i18n/locales/zh-CN/magicNotes.ts
index 0c6d956f..77c3e0d8 100644
--- a/src/renderer/src/i18n/locales/zh-CN/magicNotes.ts
+++ b/src/renderer/src/i18n/locales/zh-CN/magicNotes.ts
@@ -10,7 +10,7 @@ export const magicNotes = {
conversationScope: '保存范围:触发时完整对话的用户与助手文字正文,不含推理、工具日志、图片和附件文件。',
incomplete: '这条回答尚未完成,预览保留已有正文。', wait: '请等待回答完成或停止生成后再保存。',
tooLong: '每条记录的文字不能超过 500 KB,请缩减内容后保存。',
- required: '请输入正文并选择目标笔记,或填写新笔记标题(最多 100 字)。',
+ scopeHelp: '保存内容说明', titleTooLong: '笔记标题最多 100 字。',
saving: '正在保存…', deletedTarget: '目标笔记已删除,请重新选择;草稿已保留。',
user: '用户', assistant: '助手', loading: '正在读取完整对话…'
},
diff --git a/src/renderer/src/magic-notes-panel.css b/src/renderer/src/magic-notes-panel.css
index ab510fc3..880718b5 100644
--- a/src/renderer/src/magic-notes-panel.css
+++ b/src/renderer/src/magic-notes-panel.css
@@ -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; }
diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css
index 0697a105..cb1d74b2 100644
--- a/src/renderer/src/styles.css
+++ b/src/renderer/src/styles.css
@@ -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;
diff --git a/tests/magic-notes-footer.electron.test.ts b/tests/magic-notes-footer.electron.test.ts
new file mode 100644
index 00000000..6386224d
--- /dev/null
+++ b/tests/magic-notes-footer.electron.test.ts
@@ -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',
+ ''))
+ })
+ } }],
+ 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)
diff --git a/tests/magic-notes-layout.electron.test.ts b/tests/magic-notes-layout.electron.test.ts
index 02589d70..943ac7b6 100644
--- a/tests/magic-notes-layout.electron.test.ts
+++ b/tests/magic-notes-layout.electron.test.ts
@@ -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); });
`)
diff --git a/tests/support/magic-notes-footer-regression.tsx b/tests/support/magic-notes-footer-regression.tsx
new file mode 100644
index 00000000..5b51749b
--- /dev/null
+++ b/tests/support/magic-notes-footer-regression.tsx
@@ -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 <>
+
{}}
+ onOpenWorkspace={() => {}} onOpenSource={async () => 'opened'} onCancelCapture={() => {}} />
+ {state.confirmation}
+ >
+}
+
+const host = document.createElement('div')
+host.style.height = '100vh'
+document.body.append(host)
+createRoot(host).render()