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.
720 lines
45 KiB
Markdown
720 lines
45 KiB
Markdown
# Magic Notes Technical Design
|
||
|
||
## Main IPC Ownership
|
||
|
||
[magic-notes-ipc.ts](../../../src/main/magic-notes/magic-notes-ipc.ts) owns
|
||
note search/list/get/create/update/delete, entry create/update/delete and todo
|
||
list/status/update handlers, including conversation-source validation and entry
|
||
content mapping. Its two registration functions receive the existing tracked
|
||
`registerHandler`, window and database from [ipc.ts](../../../src/main/ipc.ts),
|
||
preserving registration order: note CRUD, entry/draft analysis, todo CRUD, then
|
||
todo analysis. [magic-notes-analysis-ipc.ts](../../../src/main/magic-notes/magic-notes-analysis-ipc.ts)
|
||
owns the three analysis handlers through two registration functions. It receives
|
||
the window, database, settings readers, usage persistence callback and existing
|
||
error formatter; it imports `createDefaultModelRuntime` directly. `ipc.ts` retains
|
||
tracked registration/removal and shared usage persistence.
|
||
Sender checks and shared input schemas stay at each handler boundary.
|
||
[rich-content.ts](../../../src/main/magic-notes/rich-content.ts) remains shared
|
||
by CRUD and draft analysis. Analysis retains revision checks, hidden task status,
|
||
stream events, error causes and nested release/dispose cleanup.
|
||
|
||
## Continuous Record Workspace
|
||
|
||
The outer workspace separates the persistent notes list from the detail container.
|
||
The list defaults to 280px, resizes within 240-420px and stores `notesPaneWidth`
|
||
and `notesPaneOpen` in `goodbuddy.magic-notes-layout.v1`. At workspace widths up to
|
||
900px, selection replaces the visible list with detail while retaining the mounted
|
||
list. The right-hand `magic-notes-detail` query container and its ResizeObserver
|
||
measure the detail width for two breakpoints: at 800px or less the record index
|
||
becomes a drawer while AI stays beside the editor; at 600px or less AI stacks
|
||
below the stream. The 900px list/detail switch is driven by the same measured
|
||
workspace width in both CSS class and React state, so the two cannot disagree.
|
||
Per-note stream scroll positions live in a workspace-local map; explicit entry
|
||
navigation takes precedence over reading-position restoration.
|
||
|
||
The detail mounts every entry in a continuous newest-first stream. The left index
|
||
and AI source buttons scroll to an entry ID without changing the active editor,
|
||
clearing a composer, flushing content or invoking the draft-switch guard. Opening
|
||
a note shows a blank composer below the title and above the stream, without a New
|
||
record button. Editing another entry or
|
||
leaving the note still checks unsaved content after flushing canvas changes.
|
||
`MagicNotesWorkspace.onBeforeLeave` registers this same check with the App navigation
|
||
gate, including sidebar, application launcher and conversation jumps. The gate waits
|
||
for canvas flush, then offers Continue editing or Discard draft and switch for dirty
|
||
body or title content. Workspace cache eviction does not serve as draft protection.
|
||
Non-title detail responses retain a dirty title. A title-save response acknowledges
|
||
only the submitted input; text entered while that request runs remains a draft.
|
||
The shared store returns `MagicNoteEntryCreateResult`, the existing detail shape
|
||
plus a required `createdEntryId`. Production IPC and preload preserve that ID.
|
||
Creating an entry selects this exact ID and resets the composer through its key;
|
||
it does not enter editing, including after automatic analysis. Text/canvas selection
|
||
is retained. Canvas capture for automatic analysis completes before the reset.
|
||
the renderer never infers it from a list difference or timestamp. Agent/MCP
|
||
creation delegates to the same store; its existing public note projection is
|
||
unchanged.
|
||
Explicit Edit opens a saved record. Update and automatic-analysis responses advance its revision, so subsequent
|
||
saves use `updateEntry` and the returned revision. Failed writes retain the draft.
|
||
|
||
The dirty baseline belongs to the mounted editor. `MagicNoteEditor.onReady` reports
|
||
Quill's normalized contents after loading; `MagicCanvasEditor.onReady` reports the
|
||
first completed core flush, after Fabric restoration and flow pagination. Canvas
|
||
input stays disabled until this baseline exists, and imperative flush waits for
|
||
the same initialization promise. Raw stored JSON is not a valid comparison baseline
|
||
for an initialized editor: Quill can add its terminal newline, and Fabric/flow
|
||
serialization can add defaults or change field order. Later changes and navigation
|
||
flushes compare with the initialized baseline without resetting it. A successful
|
||
save remounts at the returned revision and establishes a new baseline; an unchanged
|
||
background revision retains the existing editor and baseline. Aborted canvas
|
||
initialization and file reads for an unmounted text editor cannot write back.
|
||
|
||
The real saved formatted-text probe reproduced identical values with different
|
||
Delta key order: storage returned `{insert, attributes}` and Quill emitted
|
||
`{attributes, insert}`. The old `JSON.stringify` comparison therefore reported
|
||
dirty after a successful save. The saved PDF probe's initialized content and
|
||
subsequent flush were byte-for-byte equal; readiness is still awaited for canvas
|
||
restoration rather than assumed from the save response.
|
||
|
||
Saving or leaving an editing context clears the immediate-analysis timer, queued
|
||
content and streamed draft comments through the shared draft-analysis cleanup.
|
||
The context counter rejects late results and prevents dispatch after abandoned
|
||
settings preparation. This cleanup does not clear the new-entry composer's content.
|
||
|
||
The left index is collapsed by default (`recordIndexOpen`, default `false`); when
|
||
open it defaults to 168px and resizes from its right edge between 140px
|
||
and 320px. It shares the AI pane's pointer-capture handlers, separator styling
|
||
and keyboard semantics (16px arrow steps, Home/End limits). Both widths are
|
||
stored in `goodbuddy.magic-notes-layout.v1`; missing index width defaults to
|
||
168px. Displayed widths clamp together to reserve at least 300px for the stream,
|
||
without overwriting saved widths when the window shrinks. The legacy
|
||
`indexPaneOpen`/`aiPaneOpen` flags are ignored so existing users get the compact
|
||
defaults once. With no note open the workspace renders only the list, full
|
||
width, as a card grid (`magic-notes-workspace--overview`); the split layout
|
||
(`--notes`) exists only while a note is open on a wide workspace. The page
|
||
header shows the library switch in the overview and a labelled "Back to
|
||
overview" button while a note is open. Note controls live in a sticky icon
|
||
toolbar at the top of the stream (notes list, record index, entry count/updated
|
||
time, AI). Collapsing hides the entire
|
||
index and removes its grid column, border, resize separator and occupied width.
|
||
|
||
AI visibility is derived, not stored directly: `aiPaneOpen = aiPanePinned ||
|
||
(hasAiActivity && dismissedNoteId !== detail.id)`, where activity means saved
|
||
comments, draft analyses, or a running/streaming analysis. Hiding records the
|
||
current note as dismissed and clears the pin; starting a new analysis clears the
|
||
dismissal. Opening AI with no activity sets `aiPanePinned`, which is persisted.
|
||
|
||
The new-entry composer stays mounted. It collapses with CSS
|
||
(`:not(.magic-note-composer--active):not(:focus-within)`) so drafts, focus
|
||
management and the unsaved-draft guard are unchanged; `composerHasDraft` tracks
|
||
non-empty text so a draft never collapses after blur. At container widths of
|
||
800px or less, the index defaults to hidden and the same toolbar toggle opens or
|
||
closes a fixed 168px drawer over the stream, without reserving a rail or showing
|
||
an index resize separator; choosing
|
||
an item or pressing Escape closes it. AI remains a resizable right column until the
|
||
detail container is 600px or less, then moves below the stream, limited to 40%
|
||
of layout height and 280px. Overview search, scroll, task selection and return
|
||
focus stay mounted.
|
||
|
||
`MagicCanvasThumbnail` lazily queues visible records through a temporary core
|
||
renderer and caches results by content identity in a WeakMap. Core `capturePages`
|
||
accepts `firstPageOnly` and `thumbnailWidth`; thumbnails capture page zero at 240px
|
||
width, including paper/PDF background, Quill flow, images and Fabric objects.
|
||
They do not capture all pages or leave an editor mounted for every record.
|
||
An abort listener calls core `destroy()` immediately: PDF render cancellation and
|
||
the Fabric abort happen synchronously, before waiting for capture to settle.
|
||
The `finally` path awaits the same destruction promise and removes the temporary
|
||
surface, without destroying twice or waiting for capture before cancellation.
|
||
AI capture uses the configured page prefix described under Canvas Analysis and
|
||
Comments. Main canvas page height, wheel handling and file storage are unchanged.
|
||
|
||
Background refresh preserves dirty editing content and its original optimistic
|
||
revision, including externally deleted entries. Clean editors adopt refreshed
|
||
entries; deleting a clean selection updates `selectedEntryId` to a remaining
|
||
entry or the empty state. Later appends preserve that actual selection, rather
|
||
than recalculating a render-only fallback. Dirty deleted entries remain open
|
||
until explicitly discarded. No background refresh saves a draft.
|
||
|
||
## Canvas Storage Contract
|
||
|
||
`MagicNoteContent` is the union of unchanged v1 Quill rich content and v2
|
||
`paged-canvas`. The shared schema is authoritative. Canvas pages carry dimensions,
|
||
template/PDF backgrounds, opaque Fabric object records and optional `flowAuto`.
|
||
The optional Quill flow accepts native PeopleLib `version: 1` and page-break
|
||
embeds. Template names are `blank`, `ruled`, `grid`, `dot`; PDF backgrounds use
|
||
one-based `pageNumber` and optional extracted `text`.
|
||
|
||
API assets are document-local `{id,name,mimeType,dataUrl}` records. Backgrounds and
|
||
image objects reference stable asset IDs; image objects can also embed raster
|
||
data directly. Main validates data encoding, image/PDF signatures and references.
|
||
Canvas has no v1 resource-size budget. `image_bytes` counts decoded embedded
|
||
resource bytes for new entries. Bodies and binary assets use the file storage
|
||
contract below; there is no separate resource table, resource staging or extra
|
||
resource IPC. Renderer, preload and IPC continue to exchange hydrated v1/v2
|
||
content with data URLs.
|
||
|
||
Schema 38 replaces SQLite body payloads with file markers. Schema 37 was the
|
||
earlier canvas compatibility marker; its embedded-payload layout is historical.
|
||
Plain text combines flow text, PDF extracted text and object text for search.
|
||
Checklist indexes depend only on flow checklist lines; moving objects does not
|
||
change todo identity. Completion updates flow without replacing pages or assets.
|
||
Entry edits retain optimistic revisions. Canvas comment retention depends on the
|
||
actual analysis input, as described below.
|
||
|
||
`note_get` reports content kind/version and `plainTextEditable`. The text update
|
||
tool rejects canvas entries before mutation. These tools execute in the desktop
|
||
gateway; remote gbagent's injected image MCP server is unaffected. Browser page
|
||
renders can be sent as `analysisOptions.canvasImages`; analysis comments and draft
|
||
results can retain the actual `inputMode` used.
|
||
|
||
## File Storage and Writes
|
||
|
||
`MagicNoteStorage` owns files under `dirname(assistantDatabasePath)/notes`:
|
||
|
||
```text
|
||
notes/<noteId>/
|
||
note.json # Derived {id, entries} membership manifest
|
||
entries/<entryId>.json # Authoritative body envelope
|
||
assets/<sha256>.<ext> # Binary assets deduplicated within this note
|
||
```
|
||
|
||
Each body envelope contains `id`, `noteId`, `revision`, `updatedAt` and `content`.
|
||
SQLite owns note metadata, entry membership, search/plain-text and byte indexes,
|
||
optimistic revisions, todos and comments. Entry `content_json` contains only
|
||
`{storage: "file", version: <content version>, revision: <body revision>}`.
|
||
Comment-only revision increments do not change the indexed body revision.
|
||
|
||
Known media fields become `{$asset: "assets/<sha256>.<ext>", mimeType: "..."}`
|
||
on disk and hydrate back to data URLs on API reads. These fields are v1
|
||
`insert.image`, `localVideo.dataUrl` and `attachment.dataUrl`; canvas
|
||
`assets[].dataUrl`, `preview`, flow embeds and image-object `src`, including
|
||
nested objects. Ordinary text, insert strings, names and arbitrary `$asset`
|
||
objects remain unchanged. Canvas asset IDs and PDF references are preserved.
|
||
Write, read and garbage collection use the same scoped field traversal.
|
||
|
||
Revision checks run under `BEGIN IMMEDIATE` before file writes. Assets are
|
||
written before the temporary body file is atomically renamed over the entry
|
||
file; the database then commits its marker and indexes. This is not a single
|
||
atomic transaction spanning SQLite and the filesystem. If a replacement body
|
||
was renamed but indexing failed, the next database access or reopen reads it
|
||
back, repairs plain text, byte counts and todos, advances revisions and
|
||
invalidates old analysis. Missing bodies raise an error instead of becoming
|
||
empty content. Failed creates leave no accepted database membership; their
|
||
unaccepted files are removed on the next access or reopen.
|
||
|
||
After commit, reconciliation derives `note.json` from database membership and
|
||
inspects all retained entry files before removing unreferenced assets. Startup
|
||
repair reads each current body once to inspect its revision and collect asset
|
||
references, then passes the complete per-note reference set to reconciliation.
|
||
If media inspection fails, cleanup uses its existing deferred retry path rather
|
||
than trusting a partial set. Revision mismatch recovery still hydrates the body;
|
||
legacy conversion still verifies its written content. Manifest or
|
||
GC failures log `console.warn` with the note ID and error and remain in an
|
||
in-memory retry set. Later database access retries; reopening derives cleanup
|
||
again from membership. These cleanup failures do not turn a committed save,
|
||
delete or reset into a failed operation or suppress its notifications. Body
|
||
persistence and index-repair failures still propagate. Entry deletion, note
|
||
deletion and assistant-data reset share this cleanup path; deferred whole-tree
|
||
cleanup checks current membership so it preserves notes created after reset.
|
||
There is no persistent retry journal, and renderer-only drafts create no files.
|
||
|
||
## Schema 38 Migration
|
||
|
||
The existing startup storage worker runs before the business connection opens.
|
||
It upgrades the schema and converts legacy payloads one entry per SQLite
|
||
transaction: write assets and body, read back hydrated content, compare JSON
|
||
equality, then replace the old `content_json` payload with its file marker.
|
||
Already converted entries are reconciled on retry. Matching body revisions do
|
||
not require rehydrating binary payloads. Direct `AssistantDatabase.initialize`
|
||
callers and tests also run storage repair.
|
||
|
||
Space reclamation is separate from structure upgrades and conversion. Startup
|
||
detects legacy note payloads and retains the conversion/reclamation decision in
|
||
memory for that startup's worker retries. Only a required legacy conversion
|
||
permits checking `PRAGMA freelist_count` and running VACUUM. A structure-only
|
||
upgrade does not repair notes in the worker or reclaim ordinary free pages.
|
||
Cancellation after conversion or a VACUUM failure can retry reclamation within
|
||
the same startup. After quitting, startup checks the schema and remaining legacy
|
||
payloads again; free pages alone do not trigger another upgrade, and SQLite can
|
||
reuse them. No additional persistent migration marker is stored.
|
||
General migration requirements are in the
|
||
[database migration guide](../../development/database-migrations.md).
|
||
|
||
## Backup and Restore
|
||
|
||
A restorable backup must pair a consistent SQLite backup with the adjacent
|
||
`notes/` tree from the same period with note writes paused, or with the app
|
||
closed. Handle active WAL through SQLite's consistent backup mechanism; copying
|
||
only the database main file while it is active is insufficient. Restore both
|
||
parts together. SQLite-only backups of file-backed entries cannot recover
|
||
bodies or assets, and `note.json` alone cannot recover database metadata,
|
||
revisions, todos or comments.
|
||
|
||
The product has no one-click assistant backup action. This is an operational
|
||
backup boundary, not a new UI capability. The tested backup/restore and migration
|
||
scope, including the lack of a real user historical database run, is recorded
|
||
in [progress](./progress.md).
|
||
|
||
## Canvas Editor and Export
|
||
|
||
`MagicNotesWorkspace` selects rich-text or canvas content for new entries and
|
||
opens saved canvases in `MagicCanvasEditor`; `MagicNoteContent` delegates saved
|
||
canvas rendering to the paginated `MagicCanvasContent` viewer. The React adapter
|
||
wraps the retained PeopleLib Fabric + Quill core. Fabric owns page annotations;
|
||
Quill owns the continuous body flow. Saving awaits `flush()` so queued imports,
|
||
text edits and pagination reach the existing preload/IPC create/update handlers.
|
||
There is no autosave or separate canvas persistence path.
|
||
|
||
Editor and viewer heights follow the current paper element's displayed height
|
||
through natural CSS layout, including wrapped toolbars and viewport padding.
|
||
No fixed preview height or viewport-height cap clips the page. The outer note
|
||
stream scrolls vertically; the paper viewport retains horizontal scrolling.
|
||
View zoom is local to each mounted core instance. A displayed-size outer box and
|
||
one transformed logical-size surface keep PDF, flow and Fabric aligned; Quill
|
||
screen measurements are converted back to logical coordinates. Neither zoom nor
|
||
fit-width resize calls `onChange`, changes the persisted model, invalidates AI
|
||
comments or alters PNG/PDF export dimensions. See the
|
||
[view zoom implementation](../../../src/renderer/src/magic-canvas/README.md#view-zoom)
|
||
for controls, bounds, page-switch policy and observer cleanup.
|
||
|
||
Paper templates, pen, highlighter, whole-object eraser, selection/transforms,
|
||
floating text, images and PDF page backgrounds are integrated. Native PDF text
|
||
is stored on its background for search and analysis; scanned pages remain visual
|
||
without automatic OCR. The toolbar exports a raster PDF through jsPDF, composing
|
||
background, flow text and annotations per page. It does not retain a searchable
|
||
PDF text layer. `capturePages()` provides PNG data URLs for AI input, not a PNG
|
||
download button. Undo/redo is per annotation page or Quill mode; page operations
|
||
and PDF imports are not global undo steps.
|
||
|
||
Flow page-break removal and rejected-input history handling follow the
|
||
[canvas editing and limit rules](../../../src/renderer/src/magic-canvas/README.md#editing-and-assets).
|
||
`canvas-core.electron.test.ts` feeds the real Electron flow's before/after content
|
||
into production `AssistantDatabase` create/update and reopen, checking that the
|
||
derived checklist todo and its identity survive page-break removal. History
|
||
coverage exercises accepted typing, rejected overflow, selection and redo.
|
||
|
||
The [canvas integration reference](../../../src/renderer/src/magic-canvas/README.md)
|
||
owns core limits, import behavior and lifecycle details. Vite bundles Fabric,
|
||
Quill, jsPDF, PDF.js workers and binary assets; PDF workers use explicit module
|
||
Workers and local assets under the production `file://` CSP. The renderer does
|
||
not need Node integration or remote asset services.
|
||
|
||
## Canvas Analysis and Comments
|
||
|
||
Draft, saved-entry and todo analysis use the existing typed preload/IPC methods
|
||
and `createDefaultModelRuntime`. Each canvas request reads the current default
|
||
model profile's `supportsImageInput` (or the resolved default setting when no
|
||
profile is selected). Main resolves settings again for the actual request. This
|
||
does not change an Agent runtime or desktop-to-Agent protocol. All three handlers
|
||
read global resolved settings per request, without applying chat/project runtime
|
||
selection. The factory creates a tool-free direct `ModelAgentRuntime`, using the
|
||
resolved default model connection and that profile's headers, body options and
|
||
output limit. Environment-managed default credentials retain the settings store's
|
||
environment overrides. The existing context-compression configuration can select
|
||
a separate summary model; it does not select the main analysis model.
|
||
|
||
`magicNoteCanvasPageCount` is an application preference: an integer from 1 to 8,
|
||
defaulting to 1 when absent in historical settings. The application center and
|
||
`goodbuddy_config` application.update/get share the settings schema, store and
|
||
change event. It counts pages within the canvas being analyzed, never entries.
|
||
The first N pages follow the current `content.pages` array order, independent of
|
||
the viewed page, page IDs or PDF source page numbers. Fewer than N pages selects
|
||
the whole canvas. Saving and PDF export retain the 50-page canvas limit.
|
||
|
||
Renderer and Main share `selectMagicNotePages`. The core selects the prefix before
|
||
compositing, so it never captures 50 pages just to discard 49. Analysis capture
|
||
returns the selected pages' Quill text measured from the actual CSS columns;
|
||
binary searches locate the character boundaries for explicit and automatic page
|
||
breaks. Text-only requests skip image compositing but still read page text.
|
||
The transient `canvasPageText` IPC field carries these page IDs and text, without
|
||
changing saved canvas content. Main reads the persisted page count for all three
|
||
handlers, selects page text, PDF text, objects and images in the same order, and
|
||
ignores captures outside the prefix. A partial canvas with flow requires matching
|
||
page text; missing data fails instead of including the full cross-page flow.
|
||
Prompts state the selected and total page counts. A changed setting applies to
|
||
the next analysis; existing comments are not automatically regenerated.
|
||
|
||
| Default model capability | Analysis input and result |
|
||
| --- | --- |
|
||
| Image input supported | Renderer captures the selected prefix; Main requires each selected page's PNG/JPEG capture and sends it alongside that page's flow, PDF and object text. Comments carry `inputMode: canvas-images`. |
|
||
| No image input | Only text from the selected prefix is sent, with an explicit instruction that handwriting, images and layout were not seen. Comments carry `inputMode: text-fallback`. |
|
||
| No image input and no extracted text in the selected entry/draft pages | Analysis fails with guidance to switch to an image-capable default model; no visual understanding is claimed. |
|
||
|
||
Todo analysis includes its title and instructions plus the source canvas context,
|
||
and uses the same capability and capture rules. Non-canvas content keeps the text
|
||
analysis path. All analysis remains read-only with no tool calls.
|
||
|
||
Saved-entry requests accept `expectedRevision`; todo requests accept
|
||
`sourceEntryRevision`. Nonempty `canvasImages` or `canvasPageText` requires the corresponding revision,
|
||
even when the resolved model uses text fallback. Main loads the source after
|
||
resolving settings and checks the supplied revision before model invocation.
|
||
Existing rich-text callers may omit these fields. Entry writeback retains its
|
||
revision check; canvas todo writeback also requires the source entry revision,
|
||
including when no comments existed at request time. No additional stored snapshot
|
||
or schema migration is needed.
|
||
|
||
Canvas `immediate` mode provides an Analyze canvas draft action without a persistent
|
||
instruction banner; pen strokes do not trigger automatic requests. `after-save-auto`
|
||
prepares analysis input and then saves manually submitted content. Settings or
|
||
capture errors are caught so storage still proceeds; after a successful save,
|
||
the UI reports that automatic analysis failed. A later model error likewise
|
||
does not roll back the save. Failure to flush the document itself still prevents
|
||
saving incomplete content.
|
||
|
||
For v2-to-v2 entry updates, identical content retains saved comments. If all
|
||
comments have `text` or `text-fallback` input mode, equal analysis text from shared
|
||
`magicNoteCanvasAnalysisText(content)` (including page numbers)
|
||
also retains comments across layout-only changes. Other changed canvas content,
|
||
including layout changes with `canvas-images` comments, clears saved entry
|
||
comments and `analyzed_at`. Source canvas changes apply the same rule to saved
|
||
todo comments and increment the affected todo revision without replacing its ID.
|
||
In `after-save-auto`, visual comments trigger renewed
|
||
analysis when content changes, while existing text-only comments trigger it when
|
||
analysis text changes. A changed draft clears its prior canvas draft analysis.
|
||
The UI labels the actual input mode so a text fallback is visible to the user.
|
||
|
||
Workspace captures the saved entry and its revision before awaiting image capture;
|
||
an external refresh cannot pair an old capture with a newer revision. Automatic
|
||
post-save analysis takes the revision from the save response using `createdEntryId`
|
||
or the edited entry ID. Todo capture uses the selected source entry's actual page
|
||
IDs and revision. Renderer text-only change detection uses the same shared
|
||
analysis-text helper as persistence, so inserting a blank page before text triggers
|
||
renewed analysis when its cited page number changes. Draft analysis remains unversioned.
|
||
|
||
## Change Notifications
|
||
|
||
`AssistantDatabase` accepts `onMagicNotesChanged: () => void`. Every successful
|
||
note/entry create, update or delete emits once after the write commits, including
|
||
empty-note creation, initial content, title/pin updates and cascading deletion.
|
||
Saved note analysis, todo completion, saved todo analysis and assistant-data
|
||
clearing also emit. Revision conflicts, missing rows, rolled-back writes and
|
||
unchanged todo completion do not emit. Startup schema migrations run before the
|
||
workbench loads and do not publish notifications.
|
||
|
||
Desktop IPC and `KnowledgeMcpGateway` use this same database instance. The latter
|
||
also serves Agent tools; no tool-specific notification or daemon protocol change
|
||
is needed. Raw SQL writes from an unrelated process are outside this callback
|
||
contract.
|
||
|
||
Main queues notification delivery to the live window on `magic-notes:changed`.
|
||
The typed preload method `magicNotes.onChanged(listener)` exposes no Electron
|
||
objects and returns a function removing that listener. The existing
|
||
`onTodoStatusChanged` event remains the separate sidebar-count signal.
|
||
|
||
## Refresh Lifecycle
|
||
|
||
The overview's `libraryView` and the optional note-only `detailView` are independent:
|
||
opening a todo's source note does not lose the originating task view.
|
||
The notes list remains visible beside detail on desktop unless collapsed. On
|
||
narrow screens, or when opening a source note from To-dos, the originating list
|
||
remains mounted but hidden, retaining scroll position and filters. Returning restores the initiating control's focus and
|
||
invalidates outstanding detail requests. Initial loading fetches summaries and
|
||
todos without selecting the first note or task. Navigation away from unsaved content or
|
||
an unsaved title uses the existing draft confirmation; active writes finish
|
||
before navigation. No new persistence or IPC contract is required.
|
||
|
||
`MagicNotesWorkspace` subscribes on mount and coalesces notifications with a
|
||
100 ms debounce. Pending notifications wait for an active local save or analysis
|
||
to finish. Cleanup removes the listener, clears its timer and invalidates pending
|
||
refresh results. This does not introduce periodic database polling.
|
||
|
||
The existing list/detail loader reloads notes and todos, then the preferred note.
|
||
Request counters reject superseded results and preserve newer user selections.
|
||
Todo selection survives list reordering while the item exists; replacing the todo
|
||
snapshot also reloads the selected todo's source entry. Search, filters and layout
|
||
state are not reset.
|
||
|
||
Background application of a detail reads the current draft state after the
|
||
asynchronous fetch. A dirty title is retained, and the composer is not remounted.
|
||
A dirty entry editor uses its original editing content and revision for both its
|
||
React key and save request; external revisions cannot remount that draft or bypass
|
||
optimistic concurrency checks. Clean editors adopt newer revisions. Deleted entries
|
||
remain displayed only while their editor has a draft. If an externally deleted note has a draft, its editor remains available
|
||
with a deletion explanation instead of moving the draft to another note.
|
||
|
||
Read failures use the existing retryable refresh error and retain successful
|
||
data and drafts. Retry uses the same draft-preserving loader. Interaction rules
|
||
are owned by [the UI design system](../../../UI-DESIGN.md#137-魔法笔记).
|
||
|
||
## Todo Task View
|
||
|
||
The To-dos panel has its own search/status toolbar and independently scrolling
|
||
task list, grouped by source note. `selectedTodoId` controls one sibling detail pane;
|
||
the selected item is derived from the filtered list. Completion updates the
|
||
existing item and source cache without selecting or opening another task. Items
|
||
that no longer match the status filter leave the list. A removed focused control
|
||
returns focus to an available list/filter control.
|
||
|
||
Detail content includes instructions, the source entry, its existing note
|
||
navigation action, and AI controls/comments. AI requests still use the existing
|
||
analysis options and streaming subscription; failed analysis retains saved
|
||
comments. Source reads are guarded against superseded selections, report failures
|
||
through notifications and offer inline retry. The grid reserves both columns even
|
||
without a selection, so selecting or switching tasks cannot move list rows. The
|
||
detail is keyed by task ID to start each newly selected task at its top.
|
||
At a page container width of 700px or less, CSS hides the mounted list while a
|
||
task is selected and shows an explicit return button in the detail area. Returning
|
||
clears selection and restores row focus without scrolling; source-note return
|
||
retains selection and focuses this visible return button on narrow layouts.
|
||
There is no todo detail route or separate AI sidebar. A shared accessible resize
|
||
separator displays a 1px line with a transparent 9px hit area and 8px adjacent
|
||
content padding. It adjusts the list width, initially 320px, with a 240px minimum and at
|
||
least 300px reserved for detail. Pointer dragging and 16px arrow steps/Home/End
|
||
persist `todoPaneWidth` in `goodbuddy.magic-notes-layout.v1`. Narrow layouts hide
|
||
the separator; window constraints do not overwrite the saved desktop width.
|
||
Each source-note heading is a disclosure button with its matching task count.
|
||
Groups start expanded and can independently hide their mounted rows without
|
||
clearing the selected detail. Collapse state lasts while the group is mounted.
|
||
Note detail retains its existing AI pane preferences.
|
||
|
||
A single secondary button lives in `PageHeader.actions` while note detail is closed. Its
|
||
label and icon describe the destination, and `requestDraftSwitch` retains the
|
||
existing draft guard. Focus returns to the same `magic-library-switch` button
|
||
after switching. The overview section keeps its localized list `aria-label`;
|
||
there are no tab, tablist or tabpanel roles or tab-label references. The button
|
||
follows content width at all sizes. The shared status `SegmentedControl` follows
|
||
search in one wrapping toolbar.
|
||
|
||
Only note detail applies `magic-notes-layout--detail` to remove the layout frame
|
||
and corner radius. Its stream pane has no extra padding; editor borders and the
|
||
AI divider remain. The compact notes list and independent todo layout retain their
|
||
own spacing. These changes do not affect Main, preload or storage.
|
||
|
||
## Note List Actions
|
||
|
||
Each notes-list row has an open-detail button and a sibling action-menu button,
|
||
available while detail is open. Deleting the selected note passes through the
|
||
existing draft guard; drafts clear only after deletion succeeds. Deleting another
|
||
note retains current selection and drafts. The menu
|
||
uses the conversation action styles and shared `DestructiveConfirmActions`, and
|
||
is portalled to `document.body`. Its position follows the trigger on scrolling,
|
||
window resizing and confirmation-size changes. Entering detail, filtering out the
|
||
target or leaving the notes panel closes the menu.
|
||
|
||
Pinning uses the target summary's ID and revision through the existing update
|
||
IPC. It updates the sorted summary and matching selected detail without resetting
|
||
title, composer or entry-edit drafts. Deleting uses the explicitly confirmed
|
||
target ID and refreshes the overview after success, without opening another note.
|
||
These actions are available only in the overview. Database and Agent contracts
|
||
are unchanged.
|
||
|
||
## Note Tags
|
||
|
||
Implements [FR-7 to FR-10](./prd.md#功能要求). Schema 51 adds two tables:
|
||
|
||
- `magic_note_tags(id, name, name_key UNIQUE, created_at, updated_at)`. `name_key`
|
||
is the NFC-normalized, trimmed, lower-cased name, so `Work` and `work` share one
|
||
row and the first spelling is kept.
|
||
- `magic_note_tag_links(note_id, tag_id, position, created_at)` with a composite
|
||
primary key and `ON DELETE CASCADE` on both sides. `position` keeps the order
|
||
the user added tags in.
|
||
|
||
`magicNoteTagNameSchema` and `magicNoteTagListSchema` in
|
||
`src/shared/magic-notes-contracts.ts` hold all name rules: collapse whitespace,
|
||
1–24 characters, no commas, `#` or control characters; case-insensitive dedup;
|
||
at most 10 per note. IPC and MCP inputs go through these schemas before reaching
|
||
the database. The MCP tool schemas stay plain string arrays so the published
|
||
JSON schema remains simple; the gateway normalizes them.
|
||
|
||
Database behavior (`AssistantDatabase`):
|
||
|
||
- Summaries carry `tags: string[]` via one `json_group_array` subquery shared by
|
||
list, get and summary search. The summary search also matches tag names.
|
||
- `updateMagicNote({ tags })` replaces the full list inside the same
|
||
`BEGIN IMMEDIATE` transaction as the revision check, so a stale revision
|
||
changes nothing. `createMagicNote` accepts initial tags.
|
||
- Tags with no remaining links are deleted by the write that orphaned them
|
||
(tag replacement and note deletion). `listMagicNoteTags()` returns only
|
||
linked tags with `noteCount`, ordered by count then name.
|
||
- `listMagicNotes({ tags })` is an AND filter on `name_key`.
|
||
- `renameMagicNoteTag` updates the name, or merges when the new key belongs to
|
||
another tag: links move with `INSERT OR IGNORE`, then the source tag is
|
||
deleted. `deleteMagicNoteTag` removes the tag and its links. Both bump the
|
||
revision and `updated_at` of every affected note so open editors do not save
|
||
over the change, and both emit one `onMagicNotesChanged`.
|
||
|
||
IPC: `magic-notes:list` now returns `{ notes, tags }`; new channels
|
||
`magic-notes:rename-tag` and `magic-notes:delete-tag`; `magic-notes:update`
|
||
accepts `tags`. Preload exposes `renameTag` and `removeTag`.
|
||
|
||
Renderer: tag UI lives in `MagicNoteTags.tsx`. The workspace filters the loaded
|
||
summaries on the client (AND by tag key, plus tag names in the search box), so
|
||
filter changes do not refetch. Filter keys that no longer exist after a refresh
|
||
are ignored; renaming a filtered tag carries the filter to the new key. Tag edits
|
||
from the note header use the detail revision and `applyDetail`, so title and
|
||
composer drafts are kept.
|
||
|
||
Agent tools: `note_create` and `note_update` accept `tags` (update replaces the
|
||
list; `[]` clears it), `note_list` accepts a `tags` AND filter, and note
|
||
summaries returned to tools include `tags`. Descriptions tell the model to change
|
||
tags only when the user asks. The conversation capture panel does not edit tags
|
||
in this version.
|
||
|
||
## Agent Search Contract
|
||
|
||
`note_search` accepts an integer `limit` from 1 to 100, defaulting to 8.
|
||
The shared schema supplies both model tool definitions and MCP validation;
|
||
the existing 128 KiB output budget can reduce the returned result count.
|
||
The built-in MCP gateway returns invalid scoped-tool arguments as an
|
||
`isError: true` tool result with the field and validation message, allowing
|
||
the model to correct its arguments without an MCP internal-error response.
|
||
Validation failures do not execute the tool.
|
||
|
||
Note tools can be authorized with the knowledge-base switch off. The shared
|
||
desktop gateway binds authorization to each request, without a fixed elapsed-time
|
||
expiry, so long-running requests retain tool access. Request completion,
|
||
cancellation, failure cleanup, or gateway disposal revokes the token. Invalid or
|
||
revoked tokens report unavailable tool authorization rather than a knowledge-base
|
||
expiry error.
|
||
|
||
These note tools run in the desktop gateway. Remote gbagent ACP sessions
|
||
currently inject only the image MCP server, so this change requires no
|
||
daemon implementation update.
|
||
|
||
## Conversation Integration
|
||
|
||
The desktop API and renderer integration are implemented. Scope and behavior
|
||
are owned by the [PRD](./prd.md), [logic design](./logic-design.md) and
|
||
[UI design](./ui-design.md). Real Electron acceptance passed; focused results,
|
||
fixture repairs and non-green repository checks are recorded in [progress](./progress.md).
|
||
|
||
### Workbar and Capture
|
||
|
||
`WORKBAR_APP_DEFINITIONS` registers `notes` as an optional, closable, reorderable
|
||
singleton with application scope, no `targetRef` and `defaultOpen: false`.
|
||
`RightAssistantSidebar` opens or activates it through the existing
|
||
[workbar tab model](../assistant-workbar/terminal-tabs-prd.md), and removes it when
|
||
Magic Notes is disabled. Sidebar pinning does not control this availability.
|
||
`MagicNotesPanel` renders the compact view; `WorkbarShell` supplies the shared tab
|
||
controls. The footer shortcut and collapsed-sidebar chat-header shortcut both
|
||
activate the same instance.
|
||
|
||
`App.captureToNote` receives the message identity from `ChatTimeline`, or calls
|
||
`ensureConversationHistory` for whole-conversation capture. It freezes text and
|
||
source IDs before opening the preview and waits for queued conversation persistence
|
||
so Main can validate newly completed messages. Whole-conversation text includes
|
||
nonempty user/assistant bodies with role headings. It excludes reasoning, tool
|
||
logs and attachment binaries, and rejects streaming conversations.
|
||
|
||
`useMagicNoteDraft` lives in App and owns the draft, selected note, saving state
|
||
and one shared confirmation promise. Selection and drafts survive panel remounts
|
||
and conversation/project switches, but are not persisted across app restarts.
|
||
The guard rejects leaving while saving and confirms discarding pending input;
|
||
explicit cancellation compares text/title with their initial preview values.
|
||
Application disablement and tab closing call the same guard. Workspace navigation
|
||
also passes through the full workspace's existing leave check.
|
||
|
||
`capturedNoteContent` converts text into v1 Quill string inserts, preserving
|
||
newlines and adding the terminal newline when needed. It splits inserts at the
|
||
200,000-code-unit operation limit without splitting surrogate pairs; the complete
|
||
record still passes the shared rich-content schema. Markdown stays literal text.
|
||
No summarization or attachment copying runs during capture. New-note titles are
|
||
limited to 100 characters. A synchronous submission ref prevents duplicate writes.
|
||
|
||
`magicNotes.create({ title, content?, source? })` returns `MagicNoteCreateResult`,
|
||
with `createdEntryId` when initial content exists. `createMagicNote` inserts the
|
||
note, first entry, source and derived todos inside one SQLite transaction; failure
|
||
leaves no committed empty note and sends no success notification. This atomicity
|
||
applies to database membership; filesystem writes retain the reconciliation
|
||
boundary in [File Storage and Writes](#file-storage-and-writes).
|
||
`magicNotes.createEntry({ noteId, content, source? })` always returns the exact
|
||
`createdEntryId`. The panel uses that ID to scroll, focus and briefly highlight the
|
||
new record. Opening the full workspace passes `{noteId, entryId?, requestId}` into
|
||
its existing guarded note-navigation path.
|
||
|
||
`magicNotes.search({ query, limit? })` is exposed by `DesktopApi`, preload and
|
||
`magic-notes:search`. Trusted-sender and shared-schema validation precede
|
||
`AssistantDatabase.searchMagicNoteSummaries`. The trimmed query has a 1,000-character
|
||
limit; result limits are 1-200, defaulting to 200. The panel requests 100 results
|
||
and debounces nonempty queries by 180 ms. SQL searches note titles or indexed entry
|
||
text using `EXISTS`, so each note occurs once before `LIMIT`. Empty queries list
|
||
notes; ordering is pinned first, then updated time and row ID descending.
|
||
`%`, `_` and backslashes are escaped for literal matching. Agent `note_search`
|
||
retains its separate entry-level output and 1-100 limit.
|
||
|
||
### Source Metadata
|
||
|
||
`magicNoteSourceSchema` defines `kind` (`message` or `conversation`),
|
||
`conversationId`, nonempty unique `messageIds`, ISO `capturedAt`,
|
||
`conversationTitle`, and optional `projectId` / `projectName`. A message source
|
||
requires exactly one message ID. Create/create-entry inputs accept `source`;
|
||
note creation requires initial content when source is supplied. Entry reads
|
||
return it independently of editable content, while update inputs cannot replace it.
|
||
|
||
The IPC helper `resolveMagicNoteSource` loads the stored conversation and verifies
|
||
that every message ID belongs to it; a single-message source must identify an
|
||
assistant message. It replaces renderer-supplied title/project labels with current
|
||
database values while retaining the supplied capture time and IDs. Preview labels
|
||
therefore describe capture time, but stored labels describe save time. Missing
|
||
source rows reject creation; later source deletion does not block note reads or edits.
|
||
|
||
Schema 48 adds nullable `magic_note_entries.source_json` in a transaction and
|
||
advances `PRAGMA user_version`. Existing entries read without a source. It adds no
|
||
conversation foreign key or deletion cascade, and does not rewrite file-backed
|
||
bodies. Schema 38 remains the historical body migration. Structure-only upgrade
|
||
does not independently trigger body conversion or VACUUM; see the
|
||
[database migration guide](../../development/database-migrations.md). Coordinated
|
||
SQLite/files backups include the source metadata.
|
||
|
||
`MagicNoteSource` is shared by panel summaries and workspace records. Its callback
|
||
`App.openNoteSource` reads `conversations.get`, then navigates through the workspace
|
||
leave guard. Message navigation increases the mounted timeline range before
|
||
scrolling and focusing. A missing message opens its conversation with a notice;
|
||
a confirmed missing conversation disables that source bar's jump buttons. Other
|
||
read errors remain retryable. No deleted-source flag is stored.
|
||
|
||
### Refresh and Validation
|
||
|
||
Both views use the same Main store and `magic-notes:changed` notification. The
|
||
panel reloads search and selected detail when active, and ignores superseded
|
||
responses. Search/loading state belongs to the mounted panel; input and selected
|
||
note belong to App. A deleted selected note clears the selection and matching
|
||
target ID while preserving draft text. Read and save failures have local recovery
|
||
feedback. The full workspace retains its existing revision and dirty-editor rules.
|
||
|
||
After a successful save, only `after-save-auto` starts entry analysis using the
|
||
returned `createdEntryId` and configured comment format. Analysis errors notify
|
||
without undoing the save or recreating the entry. Other comment modes perform no
|
||
panel analysis.
|
||
|
||
Regression cases are in `MagicNotesPanel.test.tsx`, `App.test.tsx`,
|
||
`ChatTimeline.test.tsx`, `MagicNotesWorkspace.test.tsx`, workbar contract tests,
|
||
database/storage tests and production preload/IPC integration tests.
|
||
`tests/magic-notes-capture.electron.test.ts` mounts the real App with production
|
||
preload and capture IPC, backed by SQLite/files. It validates existing/new-note
|
||
saves, verbatim 502-message history, workspace/source navigation, native keyboard
|
||
input in a narrow panel, database reopen and retention after source deletion,
|
||
with zero model calls. Failed writes and external refresh retain their focused
|
||
regression coverage; this Electron result does not claim every failure path.
|
||
Repository test/typecheck failures and repaired migration fixtures are documented
|
||
separately in [progress](./progress.md).
|
||
|
||
## Validation
|
||
|
||
- `magic-note-storage.test.ts` uses real files and SQLite for migration equality,
|
||
reopen/repair, binary deduplication, coordinated backup/restore and physical
|
||
database shrink. Failure injection covers body rename, manifest/GC cleanup,
|
||
delete/reset retries, post-conversion cancellation and VACUUM retry.
|
||
- Canvas integration coverage includes `MagicCanvasEditor.test.tsx`,
|
||
`MagicNoteContent.test.tsx`, `rich-content.test.ts`,
|
||
`magic-note-analyzer.test.ts` and `tests/magic-note-canvas-text.test.ts`.
|
||
`canvas-ipc.integration.test.ts` runs production preload and registered IPC
|
||
handlers against real SQLite with mocked Electron transport and model output.
|
||
Interactive workspace/SQLite and production core export evidence is recorded
|
||
separately in [progress](./progress.md).
|
||
- `assistant-database.test.ts` checks committed writes with a second SQLite
|
||
connection, successful mutation coverage, failures, no-op completion and reset.
|
||
- `knowledge-mcp-gateway.test.ts` exercises real database CRUD through Agent/MCP
|
||
access, including revision failure without an extra notification. A real HTTP
|
||
MCP client also checks searches above ten results, the published limit schema,
|
||
invalid arguments and successful retry against SQLite.
|
||
- `magic-notes-events.test.ts` executes the production preload, checking the
|
||
channel, payload-free callback and listener removal.
|
||
- `MagicNotesWorkspace.test.tsx` covers external updates and deletion, selection,
|
||
source reload, editor identity and draft saves, coalescing, deferred writes,
|
||
asynchronous selection races, error retry and unmount cleanup. List-action
|
||
coverage includes portal placement, keyboard navigation, dismissal, delete
|
||
confirmation, target selection, revision failures and draft preservation.
|
||
|
||
Schema 48 adds source metadata to the schema 38 file-backed body layout; refresh
|
||
notifications need no separate storage or network service. Dated validation
|
||
results and remaining repository checks are owned by [progress](./progress.md).
|