fix: keep the notes list accessible in narrow workspaces

Default the notes list to 200px with a 160px minimum and hide it automatically only below 600px. Keep the detail toolbar toggle available to open a drawer, with dismissal and note-switching support. Restore reading positions after rich text content effects populate the record.

Include the existing document-table and file-tree scroll anchoring styles in this file-level commit. Validation: 143 related tests, Electron layout regression, typecheck and changed-file lint passed; repository lint is blocked by unrelated App.tsx changes.

Release note: The notes list takes less space and remains accessible through a drawer in narrow windows. Existing saved list widths are preserved.
This commit is contained in:
mesalogo
2026-10-03 16:02:27 +08:00
parent 10065da8ac
commit a620c22676
6 changed files with 169 additions and 34 deletions
+38 -4
View File
@@ -610,13 +610,13 @@ describe('MagicNotesWorkspace overview navigation', () => {
const view = render(<MagicNotesWorkspace onNotify={onNotify} />)
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(<MagicNotesWorkspace onNotify={onNotify} />)
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(<MagicNotesWorkspace onNotify={onNotify} />)
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 () => {
+43 -15
View File
@@ -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<HTMLInputElement>('#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
})
}
}}
>
<span className="magic-note-list-item__title">
{note.pinned && (
@@ -2359,9 +2379,10 @@ export function MagicNotesWorkspace({
<div
ref={workspaceRef}
aria-busy={Boolean(busy)}
className={`magic-notes-workspace${notesSplit ? ' magic-notes-workspace--notes' : ''}${libraryView === 'notes' && !detailView ? ' magic-notes-workspace--overview' : ''}${notesExpanded ? '' : ' magic-notes-workspace--list-hidden'}${isNarrowWorkspace ? ' magic-notes-workspace--narrow' : ''}${resizingPane === 'notes' ? ' magic-notes-layout--resizing' : ''}`}
className={`magic-notes-workspace${notesSplit || notesDrawerOpen ? ' magic-notes-workspace--notes' : ''}${notesDrawerOpen ? ' magic-notes-workspace--drawer' : ''}${libraryView === 'notes' && !detailView ? ' magic-notes-workspace--overview' : ''}${notesExpanded ? '' : ' magic-notes-workspace--list-hidden'}${isNarrowWorkspace ? ' magic-notes-workspace--narrow' : ''}${resizingPane === 'notes' ? ' magic-notes-layout--resizing' : ''}`}
style={{ '--magic-notes-notes-width': `${displayedNotesWidth}px` } as React.CSSProperties}
>
{notesDrawerOpen && <div className="magic-notes-list-backdrop" aria-hidden="true" onClick={closeNotesDrawer} />}
<section
aria-label={t(
libraryView === 'notes'
@@ -2370,6 +2391,12 @@ export function MagicNotesWorkspace({
)}
className={`magic-notes-overview${libraryView === 'todos' ? ' magic-notes-overview--todos' : ''}`}
hidden={libraryView === 'notes' ? !notesExpanded : Boolean(detailView)}
onKeyDown={(event) => {
if (notesDrawerOpen && event.key === 'Escape') {
event.stopPropagation()
closeNotesDrawer()
}
}}
>
{libraryView === 'notes' ? (
<div
@@ -2379,6 +2406,7 @@ export function MagicNotesWorkspace({
<div className="magic-notes-pane-heading">
<strong>{t('notes.heading')}</strong>
<span>{notes.length}</span>
{notesDrawerOpen && <button type="button" className="icon-button" aria-label={t('actions.hideNotes')} onClick={closeNotesDrawer}><PanelLeftClose aria-hidden="true" size={16} /></button>}
<MagicNoteTagManagerButton
compact={Boolean(detailView)}
disabled={Boolean(busy)}
@@ -2744,11 +2772,11 @@ export function MagicNotesWorkspace({
}}
>
<div className="magic-note-detail-toolbar" role="toolbar" aria-label={t('notes.toolbarLabel')}>
{libraryView === 'notes' && !isNarrowWorkspace && (
<button type="button" className="icon-button" aria-controls="magic-library-panel-notes"
{libraryView === 'notes' && (
<button id="magic-notes-list-toggle" type="button" className="icon-button" aria-controls="magic-library-panel-notes"
aria-expanded={notesExpanded} aria-label={t(notesExpanded ? 'actions.hideNotes' : 'actions.showNotes')}
title={t(notesExpanded ? 'actions.hideNotes' : 'actions.showNotes')}
onClick={() => setNotesPaneOpen((current) => !current)}>
onClick={() => isNarrowWorkspace ? setNarrowNotesOpen((current) => !current) : setNotesPaneOpen((current) => !current)}>
{notesExpanded ? <PanelLeftClose aria-hidden="true" size={16} /> : <PanelLeftOpen aria-hidden="true" size={16} />}
</button>
)}
+33 -1
View File
@@ -1283,6 +1283,7 @@
}
.magic-notes-workspace {
position: relative;
display: grid;
flex: 1;
min-width: 0;
@@ -1291,7 +1292,7 @@
}
.magic-notes-workspace--notes {
grid-template-columns: var(--magic-notes-notes-width, 280px) 9px minmax(0, 1fr);
grid-template-columns: var(--magic-notes-notes-width, 200px) 9px minmax(0, 1fr);
}
.magic-notes-workspace--list-hidden,
@@ -1327,6 +1328,24 @@
gap: var(--space-2);
}
.magic-notes-list-backdrop {
position: absolute;
inset: 0;
z-index: 4;
background: var(--overlay-backdrop);
}
.magic-notes-workspace--drawer > .magic-notes-overview {
position: absolute;
inset: 0 auto 0 0;
z-index: 5;
width: min(var(--magic-notes-notes-width, 200px), calc(100% - 32px));
padding: var(--space-2);
background: var(--surface-raised);
border-right: 1px solid var(--border-default);
box-shadow: var(--shadow-dialog);
}
.magic-notes-workspace--notes .magic-notes-pane-heading {
min-height: var(--magic-notes-heading-height);
}
@@ -15302,6 +15321,14 @@ textarea.field-control {
overflow-wrap: anywhere;
}
/* Windowed document table (P5): the table keeps the reader's row in place by
correcting the page's scrollTop; native scroll anchoring on the page could
pick a spacer and fight the correction. */
.workspace-panel-scroll:has(.knowledge-documents__table-scroll--windowed),
.knowledge-documents__table-scroll--windowed {
overflow-anchor: none;
}
/* Windowed document table (P5): spacer rows stand in for unmounted rows. */
.knowledge-documents__table-scroll tbody tr.knowledge-documents__spacer {
border: 0;
@@ -19431,6 +19458,11 @@ textarea.field-control {
min-height: 0;
overflow: auto;
}
/* A windowed tree keeps the reader's row in place itself; native scroll
anchoring could pick a spacer and fight the correction. */
.workspace-files__files-view--windowed {
overflow-anchor: none;
}
.workspace-git__changes {
flex: 1;
min-height: 0;