feat: add magic note tags and a two-pane notes layout

Keep the notes list beside the open note, compact the detail view with a full-width sticky toolbar, and make Back to overview return to a full-width all-notes grid. Add manual note tags with AND filtering, a tag manager for rename, merge and delete, schema 51 tag tables, and tag support in the note Agent tools.
This commit is contained in:
mesalogo
2026-09-30 19:04:47 +08:00
parent ab85e72f01
commit 6edfc0046b
28 changed files with 2736 additions and 370 deletions
+89 -5
View File
@@ -34,7 +34,9 @@ it('resizes record columns with native Electron input and preserves desktop widt
app.whenReady().then(async () => {
const win = new BrowserWindow({ show: true, width: 1280, height: 800, useContentSize: true,
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } });
const js = code => win.webContents.executeJavaScript(code).catch(error => { throw new Error(code + ': ' + error.message); });
let lastCommand = 'startup';
const watchdog = setTimeout(() => { console.error('Electron stalled at: ' + lastCommand); app.exit(1); }, 75000);
const js = code => { lastCommand = code; return win.webContents.executeJavaScript(code).catch(error => { throw new Error(code + ': ' + error.message); }); };
const settle = () => js('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
const screenshot = async name => {
if (process.env.GOODBUDDY_NOTES_SCREENSHOT_DIR) {
@@ -71,20 +73,74 @@ it('resizes record columns with native Electron input and preserves desktop widt
const open = async () => { await wait('!!document.querySelector("[id^=magic-note-select-]")');
await click('[id^=magic-note-select-]'); await wait('!!document.querySelector(".magic-note-record")'); await settle(); };
await win.loadURL(${JSON.stringify(server.resolvedUrls!.local[0] + 'layout.html')});
await wait('!!document.querySelector(".magic-note-tag-filter")'); await js('document.fonts.ready'); await settle();
// Overview: the tag filter row fits the page and cards cap chips at three plus a count.
assert(await js('document.documentElement.scrollWidth <= innerWidth'));
assert.equal(await js('document.querySelectorAll("#magic-note-select-layout-note .magic-note-tag-chip").length'), 4);
assert.equal(await js('document.querySelector("#magic-note-select-layout-note .magic-note-tag-chip--more").textContent'), '+1');
const card = await rect('#magic-note-select-layout-note'), chips = await rect('#magic-note-select-layout-note .magic-note-tag-chips');
assert(chips.x + chips.width <= card.x + card.width + 0.5, 'Card chips stay inside the card');
await screenshot('magic-notes-overview-tags');
await click('#magic-note-tag-manager');
await wait('!!document.querySelector(".magic-note-tag-manager")');
await screenshot('magic-notes-tag-manager');
await key('.magic-note-tag-manager', 'Escape');
await wait('!document.querySelector(".magic-note-tag-manager")');
await open(); await js('document.fonts.ready'); win.focus(); win.webContents.focus();
assert.equal(await js('document.querySelectorAll(".magic-note-tag-editor .magic-note-tag-chip--removable").length'), 4);
assert.equal(await js('getComputedStyle(document.querySelector("#magic-note-tag-manager")).width'), '28px', 'Split list uses the compact icon');
// The sticky toolbar spans the full stream width even though content is capped at 880px.
const toolbarBox = await rect('.magic-note-detail-toolbar'), streamBox = await rect('.magic-notes-stream-pane');
const streamClient = await js('document.querySelector(".magic-notes-stream-pane").clientWidth');
assert(Math.abs(toolbarBox.x - streamBox.x) <= 1 && Math.abs(toolbarBox.width - streamClient) <= 1, 'Toolbar spans the stream: ' + JSON.stringify({ toolbarBox, streamBox, streamClient }));
assert.equal((await rect('.magic-notes-overview')).width, 280);
assert(await js('document.querySelector(".magic-notes-overview").checkVisibility()'));
await js('document.querySelector(".magic-notes-card-grid").scrollTop = 300; document.querySelector(".magic-notes-stream-pane").scrollTop = 420');
await wait('document.querySelector(".magic-notes-stream-pane").scrollTop === 420');
await settle();
await click('#magic-note-select-other-note-3');
await wait('document.querySelector(".magic-note-detail-title").value === "Other note 3"');
assert.equal(await js('document.querySelector(".magic-notes-card-grid").scrollTop'), 300);
assert.equal(await js('document.querySelector(".magic-notes-stream-pane").scrollTop'), 0);
await click('#magic-note-select-layout-note');
await wait('document.querySelector(".magic-notes-stream-pane").scrollTop === 420');
assert.equal(await js('document.querySelector("#magic-note-select-layout-note").getAttribute("aria-current")'), 'true');
await drag('.magic-notes-notes-resize-handle', 40);
const notesWidth = (await rect('.magic-notes-overview')).width;
assert(notesWidth >= 320 && notesWidth <= 325);
await key('.magic-notes-notes-resize-handle', 'Left');
assert.equal((await rect('.magic-notes-overview')).width, notesWidth - 16);
await click('button[aria-controls="magic-library-panel-notes"]');
assert.equal((await rect('.magic-notes-overview')).width, 0);
await click('button[aria-controls="magic-library-panel-notes"]');
assert.equal((await rect('.magic-notes-overview')).width, notesWidth - 16);
assert.equal(await js('document.querySelector(".magic-notes-card-grid").scrollTop'), 300);
await key('.magic-notes-notes-resize-handle', 'Home');
assert.equal((await rect('.magic-notes-overview')).width, 240);
await js('document.querySelector(".magic-notes-stream-pane").scrollTop = 0');
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';
// Idle composer is a single prompt line without toolbar or footer.
const idleHeight = (await rect('.magic-note-composer')).height;
assert(idleHeight <= 60, 'Idle composer height: ' + idleHeight);
assert.equal((await rect(saveButton)).width, 0);
assert.equal(await js('document.querySelector(".magic-notes-ai-pane")?.checkVisibility() ?? false'), false, 'AI stays closed for notes without comments');
assert.equal((await rect('.magic-notes-index-pane')).width, 0, 'Record index starts collapsed');
const focusComposer = async () => { await js('document.querySelector(' + JSON.stringify(editor) + ').focus()'); await settle(); };
await focusComposer();
const emptyHeight = (await rect(editor)).height;
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);
assert.equal(await js('document.querySelector(".magic-note-detail-title").value'), 'Layout note');
assert.equal(await js('document.querySelector("#magic-notes-title").textContent'), 'Magic Notes');
await screenshot('magic-notes-light');
await js('document.documentElement.dataset.theme = "dark"'); await settle();
assert.equal((await rect(editor)).height, emptyHeight);
await screenshot('magic-notes-dark');
await js('document.documentElement.dataset.theme = "light"'); await settle();
const index = '.magic-notes-index-pane', handle = '.magic-notes-index-resize-handle', stream = '.magic-notes-stream-pane';
await click('#magic-notes-index-toggle');
await click('button[aria-controls="magic-notes-ai-pane"]');
assert.equal((await rect(index)).width, 168);
const firstRecord = await rect('.magic-note-record');
assert.equal(firstRecord.x, (await rect(index)).x, 'Thumbnails align with the left pane edge');
@@ -104,23 +160,50 @@ it('resizes record columns with native Electron input and preserves desktop widt
const withoutAi = (await rect(index)).width; assert(withoutAi < 250 && withoutAi > 230);
await click('button[aria-controls="magic-notes-ai-pane"]'); assert.equal((await rect(index)).width, withoutAi);
await win.loadURL(${JSON.stringify(server.resolvedUrls!.local[0] + 'layout.html')}); await open(); assert.equal((await rect(index)).width, withoutAi);
assert.equal((await rect('.magic-notes-overview')).width, 240);
await js('document.querySelector(".magic-notes-card-grid").scrollTop = 300');
win.setContentSize(1000, 720); await settle(); await settle();
assert.equal((await rect('.magic-notes-overview')).width, 240);
assert.equal((await rect(index)).width, 0, 'Record drawer follows detail width, not the outer page');
const assertSideBySide = async () => {
const s = await rect(stream), ai = await rect('.magic-notes-ai-pane'), list = await rect('.magic-notes-overview');
assert(s.x >= list.x + list.width, 'Note stream must not overlap the notes list');
assert(ai.x >= s.x + s.width && ai.y === s.y, 'AI pane sits beside the note stream: ' + JSON.stringify({ s, ai }));
assert(s.width >= 300, 'Note stream keeps its minimum width: ' + s.width);
};
await assertSideBySide();
win.setContentSize(930, 720); await settle(); await settle();
await assertSideBySide();
win.setContentSize(1000, 720); await settle(); await settle();
assert(await js('document.documentElement.scrollWidth <= innerWidth'));
win.setContentSize(820, 720); await settle(); await settle();
assert((await rect(stream)).width >= 299);
const constrained = { index: (await rect(index)).width, editor: (await rect(stream)).width, ai: (await rect('.magic-notes-ai-pane')).width };
win.setContentSize(720, 640); await settle(); await settle();
assert.equal((await rect('.magic-notes-overview')).width, 0);
await click('#magic-notes-back');
assert(await js('document.querySelector(".magic-notes-overview").checkVisibility()'));
assert.equal(await js('document.querySelector(".magic-notes-card-grid").scrollTop'), 300);
await open();
assert.equal((await rect(index)).width, 0);
await click('#magic-notes-index-toggle'); assert.equal((await rect(index)).width, 168);
assert.equal(await js('document.querySelector(".magic-notes-index-resize-handle")'), null);
assert.equal(await js('getComputedStyle(document.querySelector(".magic-notes-ai-resize-handle")).display'), 'none');
assert.equal(await js('getComputedStyle(document.querySelector(".magic-notes-ai-resize-handle")).display'), 'block');
assert((await rect('.magic-notes-ai-pane')).x >= (await rect(stream)).x + (await rect(stream)).width);
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);
await focusComposer();
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(560, 640); await settle(); await settle();
assert((await rect('.magic-notes-ai-pane')).y >= (await rect(stream)).y + (await rect(stream)).height, 'AI pane stacks below the stream on very narrow widths');
assert.equal(await js('getComputedStyle(document.querySelector(".magic-notes-ai-resize-handle")).display'), 'none');
win.setContentSize(1280, 800); await settle(); await settle();
assert.equal((await rect('.magic-notes-overview')).width, 240);
assert.equal((await rect(index)).width, withoutAi);
await key('.magic-note-record', 'Tab');
await js('document.querySelector(".magic-note-record").focus()');
@@ -168,6 +251,7 @@ it('resizes record columns with native Electron input and preserves desktop widt
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, desktopSaveWidth, narrowSave, narrowFooter, footerPaddingRight, persisted: true }));
clearTimeout(watchdog);
app.exit(0);
}).catch(error => { console.error(error); app.exit(1); });
`)
@@ -44,7 +44,7 @@ async function notesAnalysisRegression() {
}
throw new Error(`Timed out: ${predicate}`)
}
const button = (label: string, parent: ParentNode = host) => [...parent.querySelectorAll<HTMLButtonElement>('button')].find(node => node.textContent?.trim() === label)!
const button = (label: string, parent: ParentNode = host) => [...parent.querySelectorAll<HTMLButtonElement>('button')].find(node => (node.getAttribute('aria-label') ?? node.textContent?.trim()) === label)!
const click = async (label: string, parent: ParentNode = host) => {
await wait(() => button(label, parent) && !button(label, parent).disabled)
button(label, parent).click()
@@ -77,7 +77,6 @@ async function notesAnalysisRegression() {
await click('Save changes')
await wait(async () => (await entry()).revision >= manualRevision + 2)
const editedRevision = (await entry()).revision
await click('Cancel')
await wait(() => host.querySelector('.magic-note-composer'))
await click('Canvas', host.querySelector('.magic-note-composer')!)
const composer = () => host.querySelector<HTMLElement>('.magic-note-composer')!
@@ -8,14 +8,24 @@ import '../../src/renderer/src/magic-canvas.css'
await i18n.changeLanguage('en-US')
const note = {
id: 'layout-note', title: 'Layout note', preview: 'Record preview', entryCount: 1,
pinned: false, revision: 1, createdAt: '2026-09-19T00:00:00Z', updatedAt: '2026-09-19T00:00:00Z',
pinned: false, tags: ['Work', 'Release planning', 'Q4', 'Long tag name here'], revision: 1, createdAt: '2026-09-19T00:00:00Z', updatedAt: '2026-09-19T00:00:00Z',
entries: [{ id: 'layout-entry', noteId: 'layout-note', revision: 1,
createdAt: '2026-09-19T00:00:00Z', updatedAt: '2026-09-19T00:00:00Z',
content: { version: 1, ops: [{ insert: 'Record preview\n' }] }, plainText: 'Record preview', comments: [] }]
content: { version: 1, ops: [{ insert: 'Record preview\n'.repeat(60) }] }, plainText: 'Record preview', comments: [] }]
}
const notes = [note, ...Array.from({ length: 24 }, (_, index) => ({
...note, id: `other-note-${index}`, title: `Other note ${index}`, tags: index % 3 === 0 ? ['Work'] : [],
entries: note.entries.map(entry => ({ ...entry, id: `other-entry-${index}`, noteId: `other-note-${index}` }))
}))]
const tags = [
{ id: 'tag-work', name: 'Work', noteCount: 9 },
{ id: 'tag-release', name: 'Release planning', noteCount: 1 },
{ id: 'tag-q4', name: 'Q4', noteCount: 1 },
{ id: 'tag-long', name: 'Long tag name here', noteCount: 1 }
]
window.goodbuddy = {
magicNotes: {
list: async () => ({ notes: [note] }), get: async () => note, listTodos: async () => ({ todos: [{
list: async () => ({ notes, tags }), get: async (id: string) => notes.find(note => note.id === id), listTodos: async () => ({ todos: [{
id: 'layout-todo', noteId: note.id, noteTitle: note.title, entryId: 'layout-entry',
title: 'Layout task', instructions: 'Task details', completed: false, revision: 1,
comments: [], createdAt: note.createdAt, updatedAt: note.updatedAt
@@ -49,14 +49,14 @@ async function notesNavigationRegression() {
}
throw new Error('Timed out waiting for notes UI')
}
const button = (text: string) => [...host.querySelectorAll<HTMLButtonElement>('button')].find(item => item.textContent?.trim() === text)!
const button = (text: string) => [...host.querySelectorAll<HTMLButtonElement>('button')].find(item => (item.getAttribute('aria-label') ?? item.textContent?.trim()) === text)!
const frame = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
await wait(() => host.querySelector('[id^="magic-note-select-"]'))
host.querySelector<HTMLButtonElement>('[id^="magic-note-select-"]')!.click()
await wait(() => button('Analyze with AI'))
button('Analyze with AI').click()
await wait(() => finishAnalysis)
const title = host.querySelector<HTMLInputElement>('.magic-note-detail-header input')!
const title = host.querySelector<HTMLInputElement>('.magic-note-detail-title')!
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(title, 'Title during AI')
title.dispatchEvent(new Event('input', { bubbles: true }))
await frame()