diff --git a/docs/features/magic-notes/progress.md b/docs/features/magic-notes/progress.md index de286130..c06463b2 100644 --- a/docs/features/magic-notes/progress.md +++ b/docs/features/magic-notes/progress.md @@ -1,5 +1,20 @@ # Magic Notes Progress +## 2026-10-03: Compact Notes List And Narrow Drawer + +The notes list now defaults to 200px with a 160px minimum. At workspace widths +of 600px or more it can remain beside detail; below 600px the toolbar toggle +opens the mounted list as an overlay drawer. Escape, outside click, the close +button and note selection dismiss it. Existing desktop width preferences remain +intact. Scroll restoration now runs after read-only content effects populate the +record, fixing a failure exposed by the Electron layout regression. + +Validation: 143 workspace and list-window tests passed, along with the real +Electron layout test covering native resizing, drawer geometry, Escape focus +return and note switching. Typecheck and lint on changed code passed. Repository +lint was blocked by concurrent App.tsx changes (max-lines error and a missing +conversationStore hook dependency warning). No provider calls were made. + ## 2026-10-01: Header New Note Action New note is now one primary page-header button that always follows the view diff --git a/docs/features/magic-notes/technical-design.md b/docs/features/magic-notes/technical-design.md index 8ceea400..346d0211 100644 --- a/docs/features/magic-notes/technical-design.md +++ b/docs/features/magic-notes/technical-design.md @@ -21,13 +21,17 @@ 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 +The list defaults to 200px, resizes within 160-420px and stores `notesPaneWidth` +and `notesPaneOpen` in `goodbuddy.magic-notes-layout.v1`. Existing saved widths are +preserved. Below 600px, selection replaces the visible list with detail while +retaining the mounted list. The detail toolbar keeps its list toggle, opening an +overlay drawer without shrinking detail. Selecting a note, clicking outside, +using the drawer close button or pressing Escape closes the drawer; dismissal +returns focus to the toggle. Drawer state is transient and separate from the +desktop open preference. 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 +below the stream. The 600px 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. @@ -405,7 +409,9 @@ The overview's `libraryView` and the optional note-only `detailView` are indepen 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 +remains mounted but hidden, retaining scroll position and filters. The narrow +notes view can reopen that list through the detail toolbar drawer toggle. +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 diff --git a/src/renderer/src/MagicNotesWorkspace.test.tsx b/src/renderer/src/MagicNotesWorkspace.test.tsx index 80452036..394092b1 100644 --- a/src/renderer/src/MagicNotesWorkspace.test.tsx +++ b/src/renderer/src/MagicNotesWorkspace.test.tsx @@ -610,13 +610,13 @@ describe('MagicNotesWorkspace overview navigation', () => { const view = render() await openNote() const separator = screen.getByRole('separator', { name: '调整笔记列表宽度' }) - expect(separator).toHaveAttribute('aria-valuenow', '280') + expect(separator).toHaveAttribute('aria-valuenow', '200') fireEvent.keyDown(separator, { key: 'ArrowRight' }) - expect(separator).toHaveAttribute('aria-valuenow', '296') + expect(separator).toHaveAttribute('aria-valuenow', '216') fireEvent.keyDown(separator, { key: 'End' }) expect(separator).toHaveAttribute('aria-valuenow', '420') fireEvent.keyDown(separator, { key: 'Home' }) - expect(separator).toHaveAttribute('aria-valuenow', '240') + expect(separator).toHaveAttribute('aria-valuenow', '160') fireEvent.click(screen.getByRole('button', { name: '收起笔记列表' })) expect(screen.queryByRole('searchbox')).not.toBeInTheDocument() expect(screen.getByRole('region', { name: '笔记记录' })).toBeVisible() @@ -626,7 +626,41 @@ describe('MagicNotesWorkspace overview navigation', () => { await openNote() expect(screen.getByRole('button', { name: '展开笔记列表' })).toHaveAttribute('aria-expanded', 'false') fireEvent.click(screen.getByRole('button', { name: '展开笔记列表' })) - expect(screen.getByRole('separator', { name: '调整笔记列表宽度' })).toHaveAttribute('aria-valuenow', '240') + expect(screen.getByRole('separator', { name: '调整笔记列表宽度' })).toHaveAttribute('aria-valuenow', '160') + }) + + it.each([600, 700])('keeps the notes list beside detail at %ipx', async (width) => { + vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(width) + render() + await openNote() + expect(screen.getByRole('region', { name: '笔记列表' })).toBeVisible() + expect(screen.getByRole('separator', { name: '调整笔记列表宽度' })).toBeVisible() + }) + + it('opens the notes drawer below 600px and dismisses or switches notes', async () => { + vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(599) + render() + await openNote() + expect(screen.queryByRole('region', { name: '笔记列表' })).not.toBeInTheDocument() + const toggle = screen.getByRole('button', { name: '展开笔记列表' }) + fireEvent.click(toggle) + const sidebar = screen.getByRole('region', { name: '笔记列表' }) + expect(sidebar).toBeVisible() + expect(screen.getByRole('searchbox')).toHaveFocus() + expect(screen.queryByRole('separator', { name: '调整笔记列表宽度' })).not.toBeInTheDocument() + fireEvent.keyDown(screen.getByRole('searchbox'), { key: 'Escape' }) + expect(toggle).toHaveFocus() + expect(toggle).toHaveAttribute('aria-expanded', 'false') + fireEvent.click(toggle) + fireEvent.click(document.querySelector('.magic-notes-list-backdrop')!) + expect(toggle).toHaveAttribute('aria-expanded', 'false') + fireEvent.click(toggle) + fireEvent.click(within(sidebar).getByRole('button', { name: '收起笔记列表' })) + expect(toggle).toHaveAttribute('aria-expanded', 'false') + fireEvent.click(toggle) + await openNote(second.title) + expect(toggle).toHaveAttribute('aria-expanded', 'false') + expect(screen.getByLabelText('笔记标题')).toHaveValue(second.title) }) it('pins and deletes through the persistent sidebar while detail is open', async () => { diff --git a/src/renderer/src/MagicNotesWorkspace.tsx b/src/renderer/src/MagicNotesWorkspace.tsx index 9804cfc6..d831c7b5 100644 --- a/src/renderer/src/MagicNotesWorkspace.tsx +++ b/src/renderer/src/MagicNotesWorkspace.tsx @@ -134,7 +134,7 @@ type MagicNotesLayoutPreferences = { function loadMagicNotesLayoutPreferences(): MagicNotesLayoutPreferences { const defaults = { notesPaneOpen: true, - notesPaneWidth: 280, + notesPaneWidth: 200, recordIndexOpen: false, indexPaneWidth: defaultIndexPaneWidth, aiPanePinned: false, @@ -152,7 +152,7 @@ function loadMagicNotesLayoutPreferences(): MagicNotesLayoutPreferences { return { notesPaneOpen: parsed.notesPaneOpen !== false, notesPaneWidth: typeof parsed.notesPaneWidth === 'number' && Number.isFinite(parsed.notesPaneWidth) - ? Math.min(420, Math.max(240, parsed.notesPaneWidth)) : defaults.notesPaneWidth, + ? Math.min(420, Math.max(160, parsed.notesPaneWidth)) : defaults.notesPaneWidth, todoPaneWidth: typeof parsed.todoPaneWidth === 'number' && Number.isFinite(parsed.todoPaneWidth) ? Math.max(240, parsed.todoPaneWidth) : defaults.todoPaneWidth, // Older layouts stored always-open panes; the compact defaults ignore those flags. @@ -521,6 +521,7 @@ export function MagicNotesWorkspace({ const [indexPaneWidth, setIndexPaneWidth] = useState(initialLayoutPreferences.indexPaneWidth) const [todoPaneWidth, setTodoPaneWidth] = useState(initialLayoutPreferences.todoPaneWidth) const [notesPaneOpen, setNotesPaneOpen] = useState(initialLayoutPreferences.notesPaneOpen) + const [narrowNotesOpen, setNarrowNotesOpen] = useState(false) const [notesPaneWidth, setNotesPaneWidth] = useState(initialLayoutPreferences.notesPaneWidth) const [workspaceWidth, setWorkspaceWidth] = useState(window.innerWidth) const [todoLayoutWidth, setTodoLayoutWidth] = useState(window.innerWidth) @@ -696,19 +697,23 @@ export function MagicNotesWorkspace({ useEffect(() => { const workspace = workspaceRef.current if (!workspace) return - const update = (): void => setWorkspaceWidth(workspace.getBoundingClientRect().width || window.innerWidth) + const update = (): void => { + const width = workspace.getBoundingClientRect().width || window.innerWidth + setWorkspaceWidth(width) + if (width >= 600) setNarrowNotesOpen(false) + } update() const observer = new ResizeObserver(update) observer.observe(workspace) return () => observer.disconnect() }, [loadStatus]) - useLayoutEffect(() => { + useEffect(() => { const stream = streamRef.current if (!stream || !detail?.id) return const position = noteScrollPositions.current.get(detail.id) ?? 0 if (requestedEntryIdRef.current) return - // Read-only rich text mounts in an effect; restore after it has laid out. + // Run after the read-only rich text effects have populated their content. const frame = requestAnimationFrame(() => { stream.scrollTop = position }) return () => cancelAnimationFrame(frame) }, [detail?.id, detailView]) @@ -1168,6 +1173,7 @@ export function MagicNotesWorkspace({ return } if (target.kind === 'overview') { + setNarrowNotesOpen(false) discardComposerDraft() discardEditingDraft() detailRequestRef.current += 1 @@ -1183,6 +1189,7 @@ export function MagicNotesWorkspace({ return } if (target.kind === 'library-view') { + setNarrowNotesOpen(false) if (target.value === 'todos') { discardComposerDraft() discardEditingDraft() @@ -1218,6 +1225,7 @@ export function MagicNotesWorkspace({ setOverviewReturnNoteId(target.noteId) } setDetailView('notes') + setNarrowNotesOpen(false) focusSwitchTarget(target) void loadDetail(target.noteId, target.entryId) }, @@ -1754,14 +1762,22 @@ export function MagicNotesWorkspace({ // Detail tiers: >800 three columns; 601-800 editor + AI with a record drawer; <=600 AI stacks below. const isNarrowLayout = magicNotesLayoutWidth <= 800 const isStackedLayout = magicNotesLayoutWidth <= 600 - const isNarrowWorkspace = workspaceWidth <= 900 + const isNarrowWorkspace = workspaceWidth < 600 const paneResizeDisabled = (pane: 'ai' | 'index' | 'todo' | 'notes'): boolean => pane === 'notes' ? isNarrowWorkspace : pane === 'todo' ? todoLayoutWidth <= 700 : pane === 'ai' ? isStackedLayout : isNarrowLayout - const notesPaneWidthLimits = { minimum: 240, maximum: Math.min(420, Math.max(240, workspaceWidth - minimumMagicNotesEditorWidth - magicNotesResizeHandleWidth)) } + const notesPaneWidthLimits = { minimum: 160, maximum: Math.min(420, Math.max(160, workspaceWidth - minimumMagicNotesEditorWidth - magicNotesResizeHandleWidth)) } const displayedNotesWidth = clampMagicNotesPaneWidth(notesPaneWidth, notesPaneWidthLimits) // Overview shows every note full width; the split list appears only beside an open note. - const notesExpanded = isNarrowWorkspace ? !detailView : notesPaneOpen || !detailView + const notesDrawerOpen = libraryView === 'notes' && Boolean(detailView) && isNarrowWorkspace && narrowNotesOpen + const notesExpanded = isNarrowWorkspace ? !detailView || notesDrawerOpen : notesPaneOpen || !detailView const notesSplit = libraryView === 'notes' && Boolean(detailView) && !isNarrowWorkspace && notesPaneOpen + const closeNotesDrawer = (): void => { + setNarrowNotesOpen(false) + document.getElementById('magic-notes-list-toggle')?.focus({ preventScroll: true }) + } + useEffect(() => { + if (notesDrawerOpen) document.querySelector('#magic-library-panel-notes input[type="search"]')?.focus({ preventScroll: true }) + }, [notesDrawerOpen]) const visibleNoteIdsKey = visibleNotes.map((note) => note.id).join('\n') const visibleNoteIds = useMemo( () => (visibleNoteIdsKey ? visibleNoteIdsKey.split('\n') : []), @@ -2216,12 +2232,16 @@ export function MagicNotesWorkspace({ className="magic-note-list-item" aria-current={selectedNoteId === note.id ? 'true' : undefined} type="button" - onClick={() => - selectedNoteId !== note.id && requestDraftSwitch({ + onClick={() => { + if (selectedNoteId === note.id) { + if (notesDrawerOpen) closeNotesDrawer() + return + } + void requestDraftSwitch({ kind: 'note', noteId: note.id }) - } + }} > {note.pinned && ( @@ -2359,9 +2379,10 @@ export function MagicNotesWorkspace({
+ {notesDrawerOpen &&