Compare commits

...

12 Commits

Author SHA1 Message Date
Test
08b8995123 fix: replace breadcrumb shadow with border 2026-04-10 13:14:10 +02:00
Test
c0110d123b feat: add created dates to note list rows 2026-04-10 13:02:25 +02:00
Test
2271cb70e1 feat: add feedback modal flow 2026-04-10 12:45:37 +02:00
Test
42b1e18cc4 fix: align favorites sidebar rows 2026-04-10 12:32:22 +02:00
Test
334d8bec66 fix: hide view counts on hover 2026-04-10 12:20:06 +02:00
Test
b8983f16b2 fix: move cmd+e to toggle organized 2026-04-10 12:08:17 +02:00
Test
2fabc2a1d7 fix: restore filter field dropdown scrolling 2026-04-10 11:53:07 +02:00
Test
2f32a1781a chore: ratchet codescene thresholds to 9.68/9.33 2026-04-10 11:43:36 +02:00
Test
94ce91457d fix: support localized ai panel shortcut 2026-04-10 11:37:52 +02:00
Test
717d97f6f0 fix: keep date filter rows aligned 2026-04-09 13:39:08 +02:00
Test
727a1b95d0 fix: refresh changes after frontmatter edits 2026-04-09 13:29:33 +02:00
Test
000d89df50 fix: make ai panel shortcut command-only 2026-04-09 13:16:35 +02:00
36 changed files with 796 additions and 129 deletions

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=9.68
AVERAGE_THRESHOLD=9.32
AVERAGE_THRESHOLD=9.33

View File

@@ -35,6 +35,7 @@ const GO_ARCHIVED: &str = "go-archived";
const GO_CHANGES: &str = "go-changes";
const GO_INBOX: &str = "go-inbox";
const NOTE_TOGGLE_ORGANIZED: &str = "note-toggle-organized";
const NOTE_ARCHIVE: &str = "note-archive";
const NOTE_DELETE: &str = "note-delete";
const NOTE_OPEN_IN_NEW_WINDOW: &str = "note-open-in-new-window";
@@ -77,6 +78,7 @@ const CUSTOM_IDS: &[&str] = &[
GO_ALL_NOTES,
GO_ARCHIVED,
GO_CHANGES,
NOTE_TOGGLE_ORGANIZED,
NOTE_ARCHIVE,
NOTE_DELETE,
NOTE_OPEN_IN_NEW_WINDOW,
@@ -96,6 +98,7 @@ const CUSTOM_IDS: &[&str] = &[
/// IDs of menu items that should be disabled when no note tab is active.
const NOTE_DEPENDENT_IDS: &[&str] = &[
FILE_SAVE,
NOTE_TOGGLE_ORGANIZED,
NOTE_ARCHIVE,
NOTE_DELETE,
EDIT_TOGGLE_RAW_EDITOR,
@@ -273,9 +276,12 @@ fn build_go_menu(app: &App) -> MenuResult {
}
fn build_note_menu(app: &App) -> MenuResult {
let toggle_organized = MenuItemBuilder::new("Toggle Organized")
.id(NOTE_TOGGLE_ORGANIZED)
.accelerator("CmdOrCtrl+E")
.build(app)?;
let archive_note = MenuItemBuilder::new("Archive Note")
.id(NOTE_ARCHIVE)
.accelerator("CmdOrCtrl+E")
.build(app)?;
let delete_note = MenuItemBuilder::new("Delete Note")
.id(NOTE_DELETE)
@@ -295,13 +301,14 @@ fn build_note_menu(app: &App) -> MenuResult {
.build(app)?;
let toggle_ai_chat = MenuItemBuilder::new("Toggle AI Panel")
.id(VIEW_TOGGLE_AI_CHAT)
.accelerator("CmdOrCtrl+Shift+L")
.accelerator("Cmd+Shift+L")
.build(app)?;
let toggle_backlinks = MenuItemBuilder::new("Toggle Backlinks")
.id(VIEW_TOGGLE_BACKLINKS)
.build(app)?;
Ok(SubmenuBuilder::new(app, "Note")
.item(&toggle_organized)
.item(&archive_note)
.item(&delete_note)
.item(&restore_deleted_note)

View File

@@ -17,6 +17,7 @@ import { SettingsPanel } from './components/SettingsPanel'
import { GitHubVaultModal } from './components/GitHubVaultModal'
import { WelcomeScreen } from './components/WelcomeScreen'
import { TelemetryConsentDialog } from './components/TelemetryConsentDialog'
import { FeedbackDialog } from './components/FeedbackDialog'
import { useTelemetry } from './hooks/useTelemetry'
import { useMcpStatus } from './hooks/useMcpStatus'
import { useClaudeCodeStatus } from './hooks/useClaudeCodeStatus'
@@ -91,6 +92,9 @@ function App() {
const visibleNotesRef = useRef<VaultEntry[]>([])
const [toastMessage, setToastMessage] = useState<string | null>(null)
const dialogs = useDialogs()
const [showFeedback, setShowFeedback] = useState(false)
const openFeedback = useCallback(() => setShowFeedback(true), [])
const closeFeedback = useCallback(() => setShowFeedback(false), [])
// onSwitch closure captures `notes` declared below — safe because it's only
// called on user interaction, never during render (refs inside the hook
@@ -187,7 +191,7 @@ function App() {
onOpenFile: (relativePath) => conflictFlow.openConflictFileRef.current(relativePath),
})
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => appSave.contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry })
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => appSave.contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry, onFrontmatterPersisted: vault.loadModifiedFiles })
// Note window: auto-open the note from URL params once vault entries load
const noteWindowOpenedRef = useRef(false)
@@ -381,7 +385,6 @@ function App() {
handleUpdateFrontmatter: notes.handleUpdateFrontmatter,
handleDeleteProperty: notes.handleDeleteProperty, setToastMessage,
createTypeEntry: notes.createTypeEntrySilent,
onFrontmatterPersisted: vault.loadModifiedFiles,
onBeforeAction: appSave.flushBeforeAction,
})
@@ -510,6 +513,7 @@ function App() {
onCreateNoteOfType: notes.handleCreateNoteImmediate,
onSave: appSave.handleSave,
onOpenSettings: dialogs.openSettings,
onOpenFeedback: openFeedback,
onDeleteNote: deleteActions.handleDeleteNote,
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
onCommitPush: commitFlow.openCommitDialog,
@@ -693,7 +697,7 @@ function App() {
</div>
<UpdateBanner status={updateStatus} actions={updateActions} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} claudeCodeStatus={claudeCodeStatus} claudeCodeVersion={claudeCodeVersion} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} claudeCodeStatus={claudeCodeStatus} claudeCodeVersion={claudeCodeVersion} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
@@ -713,6 +717,7 @@ function App() {
onClose={conflictFlow.handleCloseConflictResolver}
/>
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} onClose={dialogs.closeSettings} />
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
<GitHubVaultModal
open={dialogs.showGitHubVault}
githubToken={settings.github_token}

View File

@@ -9,12 +9,12 @@ describe('ArchivedNoteBanner', () => {
expect(screen.getByText('Archived')).toBeTruthy()
})
it('renders unarchive button with keyboard hint', () => {
it('renders unarchive button without the stale archive shortcut hint', () => {
render(<ArchivedNoteBanner onUnarchive={vi.fn()} />)
const btn = screen.getByTestId('unarchive-btn')
expect(btn).toBeTruthy()
expect(btn.textContent).toContain('Unarchive')
expect(btn.title).toBe('Unarchive (Cmd+E)')
expect(btn.title).toBe('Unarchive')
})
it('calls onUnarchive when button is clicked', () => {

View File

@@ -38,7 +38,7 @@ export function ArchivedNoteBanner({ onUnarchive }: ArchivedNoteBannerProps) {
color: 'var(--muted-foreground)',
cursor: 'pointer',
}}
title="Unarchive (Cmd+E)"
title="Unarchive"
>
<ArrowUUpLeft size={12} />
Unarchive

View File

@@ -74,31 +74,38 @@ describe('BreadcrumbBar — delete', () => {
describe('BreadcrumbBar — archive/unarchive', () => {
it('shows archive button for non-archived note', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onArchive={vi.fn()} onUnarchive={vi.fn()} />)
expect(screen.getByTitle('Archive (Cmd+E)')).toBeInTheDocument()
expect(screen.queryByTitle('Unarchive (Cmd+E)')).not.toBeInTheDocument()
expect(screen.getByTitle('Archive')).toBeInTheDocument()
expect(screen.queryByTitle('Unarchive')).not.toBeInTheDocument()
})
it('shows unarchive button for archived note', () => {
render(<BreadcrumbBar entry={archivedEntry} {...defaultProps} onArchive={vi.fn()} onUnarchive={vi.fn()} />)
expect(screen.getByTitle('Unarchive (Cmd+E)')).toBeInTheDocument()
expect(screen.queryByTitle('Archive (Cmd+E)')).not.toBeInTheDocument()
expect(screen.getByTitle('Unarchive')).toBeInTheDocument()
expect(screen.queryByTitle('Archive')).not.toBeInTheDocument()
})
it('calls onArchive when archive button is clicked', () => {
const onArchive = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onArchive={onArchive} />)
fireEvent.click(screen.getByTitle('Archive (Cmd+E)'))
fireEvent.click(screen.getByTitle('Archive'))
expect(onArchive).toHaveBeenCalledOnce()
})
it('calls onUnarchive when unarchive button is clicked', () => {
const onUnarchive = vi.fn()
render(<BreadcrumbBar entry={archivedEntry} {...defaultProps} onUnarchive={onUnarchive} />)
fireEvent.click(screen.getByTitle('Unarchive (Cmd+E)'))
fireEvent.click(screen.getByTitle('Unarchive'))
expect(onUnarchive).toHaveBeenCalledOnce()
})
})
describe('BreadcrumbBar — organized shortcut hint', () => {
it('shows Cmd+E on the organized toggle tooltip', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onToggleOrganized={vi.fn()} />)
expect(screen.getByTitle('Mark as organized (remove from Inbox) (Cmd+E)')).toBeInTheDocument()
})
})
describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)', () => {
it('always renders title elements in the DOM', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
@@ -125,13 +132,22 @@ describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)',
expect(screen.getByText('Note')).toBeInTheDocument()
})
it('shadow is controlled by data-title-hidden attribute via CSS', () => {
it('separator visibility is controlled by data-title-hidden while using the shared border chrome', () => {
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
const bar = container.querySelector('.breadcrumb-bar')!
expect(bar).toHaveClass('border-b', 'border-transparent')
expect(bar).not.toHaveAttribute('data-title-hidden')
bar.setAttribute('data-title-hidden', '')
expect(bar).toHaveAttribute('data-title-hidden')
})
it('uses the active separator state when raw mode forces the title into the breadcrumb', () => {
const { container } = render(
<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode onToggleRaw={vi.fn()} />,
)
expect(container.querySelector('.breadcrumb-bar')).toHaveAttribute('data-title-hidden')
})
})
describe('BreadcrumbBar — action buttons always right-aligned', () => {

View File

@@ -81,7 +81,7 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
entry.organized ? "text-green-600" : "text-muted-foreground hover:text-foreground"
)}
onClick={onToggleOrganized}
title={entry.organized ? 'Mark as unorganized (back to Inbox)' : 'Mark as organized (remove from Inbox)'}
title={entry.organized ? 'Mark as unorganized (back to Inbox) (Cmd+E)' : 'Mark as organized (remove from Inbox) (Cmd+E)'}
>
<CheckCircle size={16} weight={entry.organized ? 'fill' : 'regular'} />
</button>
@@ -137,7 +137,7 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
<button
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-foreground"
onClick={onUnarchive}
title="Unarchive (Cmd+E)"
title="Unarchive"
>
<ArrowUUpLeft size={16} />
</button>
@@ -145,7 +145,7 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
<button
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-foreground"
onClick={onArchive}
title="Archive (Cmd+E)"
title="Archive"
>
<Archive size={16} />
</button>
@@ -204,11 +204,12 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
ref={barRef}
data-tauri-drag-region
{...(titleAlwaysVisible ? { 'data-title-hidden': '' } : {})}
className="breadcrumb-bar flex shrink-0 items-center"
className="breadcrumb-bar flex shrink-0 items-center border-b border-transparent"
style={{
height: 52,
background: 'var(--background)',
padding: '6px 16px',
boxSizing: 'border-box',
}}
>
<div className="breadcrumb-bar__title flex-1 min-w-0">

View File

@@ -22,14 +22,13 @@
opacity: 0.55;
}
/* Breadcrumb bar: title + shadow toggled via data attribute (no React re-render) */
/* Breadcrumb bar: title + border toggled via data attribute (no React re-render) */
.breadcrumb-bar {
transition: box-shadow 0.2s ease;
box-shadow: none;
transition: border-color 0.2s ease;
}
.breadcrumb-bar[data-title-hidden] {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
border-bottom-color: var(--border);
}
.breadcrumb-bar__title {

View File

@@ -0,0 +1,53 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { FeedbackDialog } from './FeedbackDialog'
import { LAPUTA_GITHUB_ISSUES_URL } from '../constants/feedback'
vi.mock('../utils/url', () => ({
openExternalUrl: vi.fn().mockResolvedValue(undefined),
}))
const { openExternalUrl } = await import('../utils/url') as typeof import('../utils/url') & {
openExternalUrl: ReturnType<typeof vi.fn>
}
describe('FeedbackDialog', () => {
it('renders the instructional copy when open', () => {
render(<FeedbackDialog open={true} onClose={vi.fn()} />)
expect(screen.getByTestId('feedback-dialog')).toBeInTheDocument()
expect(screen.getByText('Share feedback')).toBeInTheDocument()
expect(screen.getByText(/best way to share product feedback/i)).toBeInTheDocument()
expect(screen.getByText(/check whether a similar one already exists/i)).toBeInTheDocument()
})
it('focuses the primary CTA when opened', async () => {
render(<FeedbackDialog open={true} onClose={vi.fn()} />)
const cta = screen.getByRole('button', { name: 'Go to Issues' })
await waitFor(() => expect(cta).toHaveFocus())
})
it('opens GitHub Issues without closing the modal', async () => {
const onClose = vi.fn()
render(<FeedbackDialog open={true} onClose={onClose} />)
fireEvent.click(screen.getByRole('button', { name: 'Go to Issues' }))
await waitFor(() => expect(openExternalUrl).toHaveBeenCalledWith(LAPUTA_GITHUB_ISSUES_URL))
expect(onClose).not.toHaveBeenCalled()
expect(screen.getByTestId('feedback-dialog')).toBeInTheDocument()
})
it('closes when pressing Escape', () => {
const onClose = vi.fn()
render(<FeedbackDialog open={true} onClose={onClose} />)
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce()
})
it('closes when clicking Close', () => {
const onClose = vi.fn()
render(<FeedbackDialog open={true} onClose={onClose} />)
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(onClose).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,59 @@
import { Megaphone } from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { LAPUTA_GITHUB_ISSUES_URL } from '../constants/feedback'
import { openExternalUrl } from '../utils/url'
interface FeedbackDialogProps {
open: boolean
onClose: () => void
}
export function FeedbackDialog({ open, onClose }: FeedbackDialogProps) {
const handleOpenIssues = () => {
void openExternalUrl(LAPUTA_GITHUB_ISSUES_URL)
}
return (
<Dialog open={open} onOpenChange={(next) => { if (!next) onClose() }}>
<DialogContent showCloseButton={false} className="sm:max-w-[460px]" data-testid="feedback-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Megaphone size={18} weight="duotone" />
Share feedback
</DialogTitle>
<DialogDescription>
The best way to share product feedback is through a GitHub Issue.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm leading-6 text-muted-foreground">
<p>
Before opening a new issue, please check whether a similar one already exists.
If it does, add an upvote or comment there instead of opening a duplicate.
</p>
<p>
When you do open a new issue, include the steps to reproduce, what you expected,
and what actually happened so it is easier to triage.
</p>
</div>
<DialogFooter className="sm:justify-between">
<Button type="button" variant="outline" onClick={onClose}>
Close
</Button>
<Button type="button" autoFocus onClick={handleOpenIssues}>
Go to Issues
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { render, screen, fireEvent, act } from '@testing-library/react'
import { FilterBuilder } from './FilterBuilder'
import type { FilterGroup } from '../types'
@@ -135,6 +135,23 @@ describe('FilterBuilder value inputs', () => {
expect(screen.getByTestId('date-picker-trigger')).toHaveAttribute('title', 'Mar 28, 2026')
})
it('keeps filter controls top-aligned when the date preview adds a second line', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-07T12:00:00Z'))
renderBuilder({
all: [{ field: 'created', op: 'after', value: '10 days ago' }],
})
fireEvent.focus(screen.getByTestId('date-value-input'))
await act(async () => {
await vi.advanceTimersByTimeAsync(300)
})
expect(screen.getByTestId('date-value-preview')).toHaveTextContent('Resolves to March 28, 2026')
expect(screen.getByTestId('filter-row')).toHaveClass('items-start')
})
it('filters the field combobox as the user types', () => {
render(
<FilterBuilder

View File

@@ -141,7 +141,7 @@ function FilterRow({ condition, fields, onUpdate, onRemove }: {
const regexEnabled = regexSupported && condition.regex === true
const invalidRegex = regexSupported && hasInvalidRegex(String(condition.value ?? ''), regexEnabled)
return (
<div className="flex items-center gap-1.5">
<div className="flex items-start gap-1.5" data-testid="filter-row">
<FilterFieldCombobox
value={condition.field}
fields={fields}

View File

@@ -54,6 +54,62 @@ function stepHighlightedIndex(current: number, optionCount: number, direction: '
return (current - 1 + optionCount) % optionCount
}
function moveHighlightedOption({
event,
open,
options,
direction,
openCombobox,
setHighlightedIndex,
}: {
event: KeyboardEvent<HTMLInputElement>
open: boolean
options: string[]
direction: 'next' | 'previous'
openCombobox: () => void
setHighlightedIndex: (updater: number | ((current: number) => number)) => void
}) {
event.preventDefault()
if (!open) {
openCombobox()
return
}
if (options.length === 0) return
setHighlightedIndex((current) => stepHighlightedIndex(current, options.length, direction))
}
function selectHighlightedOption({
event,
open,
options,
highlightedIndex,
selectOption,
}: {
event: KeyboardEvent<HTMLInputElement>
open: boolean
options: string[]
highlightedIndex: number
selectOption: (value: string) => void
}) {
if (!open || highlightedIndex < 0 || options[highlightedIndex] === undefined) return
event.preventDefault()
selectOption(options[highlightedIndex])
}
function closeOpenCombobox({
event,
open,
closeCombobox,
}: {
event: KeyboardEvent<HTMLInputElement>
open: boolean
closeCombobox: () => void
}) {
if (!open) return
event.preventDefault()
closeCombobox()
}
function handleFilterFieldKeyDown({
event,
open,
@@ -75,32 +131,16 @@ function handleFilterFieldKeyDown({
}) {
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
if (!open) {
openCombobox()
return
}
if (options.length === 0) return
setHighlightedIndex((current) => stepHighlightedIndex(current, options.length, 'next'))
moveHighlightedOption({ event, open, options, direction: 'next', openCombobox, setHighlightedIndex })
return
case 'ArrowUp':
event.preventDefault()
if (!open) {
openCombobox()
return
}
if (options.length === 0) return
setHighlightedIndex((current) => stepHighlightedIndex(current, options.length, 'previous'))
moveHighlightedOption({ event, open, options, direction: 'previous', openCombobox, setHighlightedIndex })
return
case 'Enter':
if (!open || highlightedIndex < 0 || options[highlightedIndex] === undefined) return
event.preventDefault()
selectOption(options[highlightedIndex])
selectHighlightedOption({ event, open, options, highlightedIndex, selectOption })
return
case 'Escape':
if (!open) return
event.preventDefault()
closeCombobox()
closeOpenCombobox({ event, open, closeCombobox })
return
default:
return
@@ -178,21 +218,27 @@ function FilterFieldPopoverPanel({
<PopoverContent
align="start"
sideOffset={4}
className="max-h-60 overflow-y-auto p-1"
className="overflow-hidden p-1"
style={{ width: contentWidth }}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
data-testid="filter-field-combobox-popover"
>
<div id={listboxId} role="listbox" data-testid="filter-field-combobox-options">
<FilterFieldOptionsList
listboxId={listboxId}
fieldGroups={fieldGroups}
options={options}
highlightedIndex={highlightedIndex}
onHighlight={onHighlight}
onSelect={onSelect}
/>
<div
className="max-h-60 overflow-y-auto overscroll-contain"
data-testid="filter-field-combobox-scroll-area"
onWheelCapture={(event) => event.stopPropagation()}
>
<div id={listboxId} role="listbox" data-testid="filter-field-combobox-options">
<FilterFieldOptionsList
listboxId={listboxId}
fieldGroups={fieldGroups}
options={options}
highlightedIndex={highlightedIndex}
onHighlight={onHighlight}
onSelect={onSelect}
/>
</div>
</div>
</PopoverContent>
)

View File

@@ -12,6 +12,8 @@ const { openExternalUrl } = await import('../utils/url') as typeof import('../ut
openExternalUrl: ReturnType<typeof vi.fn>
}
const NOW_SECONDS = 1_744_286_400
describe('NoteItem', () => {
beforeEach(() => {
openExternalUrl.mockClear()
@@ -87,6 +89,60 @@ describe('NoteItem', () => {
expect(screen.queryByTestId('change-status-icon')).not.toBeInTheDocument()
})
it('adds more breathing room between note sections', () => {
const entry = makeEntry({
title: 'Spaced note',
snippet: 'Body preview',
createdAt: NOW_SECONDS - 86400 * 3,
modifiedAt: NOW_SECONDS - 86400,
properties: { Status: 'Active' },
})
render(
<NoteItem
entry={entry}
isSelected={false}
typeEntryMap={{}}
displayPropsOverride={['Status']}
onClickNote={vi.fn()}
/>,
)
expect(screen.getByTestId('note-content-stack').className).toContain('space-y-2')
})
it('shows created date on the right side of the date row when available', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(NOW_SECONDS * 1000))
const entry = makeEntry({
title: 'Dated note',
createdAt: NOW_SECONDS - 86400 * 5,
modifiedAt: NOW_SECONDS - 86400 * 2,
})
render(<NoteItem entry={entry} isSelected={false} typeEntryMap={{}} onClickNote={vi.fn()} />)
const dateRow = screen.getByTestId('note-date-row')
expect(dateRow.className).toContain('justify-between')
expect(dateRow).toHaveTextContent('2d ago')
expect(dateRow).toHaveTextContent('Created 5d ago')
})
it('leaves the right side empty when no creation date exists', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(NOW_SECONDS * 1000))
const entry = makeEntry({
title: 'Modified note',
createdAt: null,
modifiedAt: NOW_SECONDS - 3600,
})
render(<NoteItem entry={entry} isSelected={false} typeEntryMap={{}} onClickNote={vi.fn()} />)
expect(screen.getByTestId('note-date-row')).toHaveTextContent('1h ago')
expect(screen.queryByText(/Created /)).not.toBeInTheDocument()
})
it('colors relationship chips by target type and opens the related note on Cmd+click only', () => {
const linkedProject = makeEntry({
path: '/vault/project/build-app.md',

View File

@@ -163,35 +163,72 @@ function StandardNoteContent({
<>
{/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
<TypeIcon width={14} height={14} className="absolute right-3 top-2.5" style={{ color: typeColor }} data-testid="type-icon" />
<div className="pr-5">
<div className={cn('truncate text-[13px]', isBinary ? 'text-muted-foreground' : 'text-foreground', isSelected && !isBinary ? 'font-semibold' : 'font-medium')}>
{noteStatus !== 'clean' && !isBinary && <StatusDot noteStatus={noteStatus} />}
<NoteTitleIcon icon={entry.icon} size={15} className="mr-1" testId="note-title-icon" />
{entry.title}
{!isBinary && <StateBadge archived={entry.archived} />}
</div>
</div>
{entry.snippet && !isBinary && (
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" data-testid="note-snippet" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{entry.snippet}
</div>
)}
{!isBinary && displayProps.length > 0 && (
<PropertyChips
<div className="space-y-2 pr-5" data-testid="note-content-stack">
<NoteTitleRow
entry={entry}
displayProps={displayProps}
allEntries={allEntries}
typeEntryMap={typeEntryMap}
onOpenNote={onClickNote}
isBinary={isBinary}
isSelected={isSelected}
noteStatus={noteStatus}
/>
)}
{!isBinary && (
<div className="mt-0.5 text-[10px] text-muted-foreground">{relativeDate(getDisplayDate(entry))}</div>
)}
{!isBinary && entry.snippet && (
<div
className="text-[12px] leading-[1.5] text-muted-foreground"
data-testid="note-snippet"
style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}
>
{entry.snippet}
</div>
)}
{!isBinary && displayProps.length > 0 && (
<PropertyChips
entry={entry}
displayProps={displayProps}
allEntries={allEntries}
typeEntryMap={typeEntryMap}
onOpenNote={onClickNote}
/>
)}
{!isBinary && <NoteDateRow entry={entry} />}
</div>
</>
)
}
function NoteTitleRow({
entry,
isBinary,
isSelected,
noteStatus,
}: {
entry: VaultEntry
isBinary: boolean
isSelected: boolean
noteStatus: NoteStatus
}) {
return (
<div className={cn('truncate text-[13px]', isBinary ? 'text-muted-foreground' : 'text-foreground', isSelected && !isBinary ? 'font-semibold' : 'font-medium')}>
{noteStatus !== 'clean' && !isBinary && <StatusDot noteStatus={noteStatus} />}
<NoteTitleIcon icon={entry.icon} size={15} className="mr-1" testId="note-title-icon" />
{entry.title}
{!isBinary && <StateBadge archived={entry.archived} />}
</div>
)
}
function NoteDateRow({ entry }: { entry: VaultEntry }) {
const modifiedLabel = relativeDate(getDisplayDate(entry))
const createdLabel = entry.createdAt ? `Created ${relativeDate(entry.createdAt)}` : null
if (!modifiedLabel && !createdLabel) return null
return (
<div className="flex items-center justify-between gap-3 text-[10px] text-muted-foreground" data-testid="note-date-row">
<span>{modifiedLabel}</span>
{createdLabel && <span className="shrink-0">{createdLabel}</span>}
</div>
)
}
function noteItemStyle(isSelected: boolean, isMultiSelected: boolean, typeColor: string, typeLightColor: string): React.CSSProperties {
const base: React.CSSProperties = { padding: isSelected && !isMultiSelected ? '14px 16px 14px 13px' : '14px 16px' }
if (isMultiSelected) base.backgroundColor = 'color-mix(in srgb, var(--accent-blue) 10%, transparent)'

View File

@@ -1,4 +1,4 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { render, screen, fireEvent, within } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { Sidebar } from './Sidebar'
import type { VaultEntry, SidebarSelection } from '../types'
@@ -910,6 +910,53 @@ describe('Sidebar', () => {
fireEvent.click(screen.getByText('My Favorite Note'))
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'favorites' })
})
it('matches the Types row styling and type color for favorites', () => {
render(<Sidebar entries={[...mockEntries, favEntry]} selection={defaultSelection} onSelect={() => {}} />)
const favoriteLabel = screen.getByText('My Favorite Note')
const favoriteRow = favoriteLabel.closest('.cursor-pointer')
const typeLabel = screen.getByText('Projects')
const typeRow = typeLabel.closest('.cursor-pointer')
const favoriteIcon = favoriteRow?.querySelector('svg')
expect(favoriteRow?.className).toBe(typeRow?.className)
expect(favoriteRow?.style.padding).toBe(typeRow?.style.padding)
expect(favoriteRow?.style.gap).toBe(typeRow?.style.gap)
expect(favoriteLabel.className).toContain(typeLabel.className)
expect(favoriteLabel.className).toContain('truncate')
expect(favoriteIcon?.getAttribute('width')).toBe('16')
expect(favoriteIcon?.getAttribute('height')).toBe('16')
expect(favoriteIcon?.getAttribute('style')).toContain('var(--accent-red)')
})
it('falls back to a neutral icon color when the favorite type has no defined color', () => {
const customType: VaultEntry = {
path: '/vault/types/recipe.md', filename: 'recipe.md', title: 'Recipe',
isA: 'Type', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
cadence: null, archived: false, modifiedAt: 1700000000,
createdAt: null, fileSize: 120, snippet: '', wordCount: 0, relationships: {},
icon: 'flask', color: null, order: null, sidebarLabel: null, template: null,
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
}
const recipeFavorite: VaultEntry = {
path: '/vault/recipe/sourdough.md', filename: 'sourdough.md', title: 'Sourdough',
isA: 'Recipe', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
cadence: null, archived: false, modifiedAt: 1700000000,
createdAt: null, fileSize: 120, snippet: '', wordCount: 0, relationships: {},
icon: null, color: null, order: null, sidebarLabel: null, template: null,
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
favorite: true, favoriteIndex: 0,
}
render(<Sidebar entries={[...mockEntries, customType, recipeFavorite]} selection={defaultSelection} onSelect={() => {}} />)
const recipeRow = screen.getByText('Sourdough').closest('.cursor-pointer')
const recipeIcon = recipeRow?.querySelector('svg')
expect(recipeIcon?.getAttribute('style')).toContain('var(--muted-foreground)')
expect(within(recipeRow as HTMLElement).getByText('Sourdough')).toBeInTheDocument()
})
})
describe('group separators', () => {
@@ -1049,5 +1096,32 @@ describe('Sidebar', () => {
const viewContainer = screen.getByText('Empty View').closest('div')
expect(viewContainer?.querySelector('span:last-child')?.textContent).not.toBe('0')
})
it('adds hover and focus classes that hide the view count chip while showing the action buttons', () => {
render(
<Sidebar
entries={mockEntries}
selection={defaultSelection}
onSelect={() => {}}
views={mockViews}
onEditView={() => {}}
onDeleteView={() => {}}
/>
)
const label = screen.getByText('Active Projects')
const viewItem = label.closest('.group.relative') as HTMLElement
const navItem = label.closest('[class*="cursor-pointer"]') as HTMLElement
const countChip = navItem.querySelector('span:last-child') as HTMLElement
expect(countChip).toBeTruthy()
expect(viewItem.className).toContain('[&>div>span:last-child]:transition-opacity')
expect(viewItem.className).toContain('group-hover:[&>div>span:last-child]:opacity-0')
expect(viewItem.className).toContain('group-focus-within:[&>div>span:last-child]:opacity-0')
const actionButton = within(viewItem).getByTitle('Edit view')
const actionContainer = actionButton.parentElement as HTMLElement
expect(actionContainer.className).toContain('group-hover:opacity-100')
expect(actionContainer.className).toContain('group-focus-within:opacity-100')
})
})
})

View File

@@ -51,6 +51,19 @@ describe('StatusBar', () => {
expect(screen.queryByText('main')).not.toBeInTheDocument()
})
it('shows Feedback button when callback is provided', () => {
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onOpenFeedback={vi.fn()} />)
expect(screen.getByTestId('status-feedback')).toBeInTheDocument()
expect(screen.getByText('Feedback')).toBeInTheDocument()
})
it('calls onOpenFeedback when Feedback is clicked', () => {
const onOpenFeedback = vi.fn()
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onOpenFeedback={onOpenFeedback} />)
fireEvent.click(screen.getByTestId('status-feedback'))
expect(onOpenFeedback).toHaveBeenCalledOnce()
})
it('shows clickable commit hash that opens URL via openExternalUrl', () => {
render(
<StatusBar

View File

@@ -34,6 +34,7 @@ interface StatusBarProps {
onOpenConflictResolver?: () => void
zoomLevel?: number
onZoomReset?: () => void
onOpenFeedback?: () => void
buildNumber?: string
onCheckForUpdates?: () => void
onRemoveVault?: (path: string) => void
@@ -67,6 +68,7 @@ export function StatusBar({
onOpenConflictResolver,
zoomLevel = 100,
onZoomReset,
onOpenFeedback,
buildNumber,
onCheckForUpdates,
onRemoveVault,
@@ -131,6 +133,7 @@ export function StatusBar({
noteCount={noteCount}
zoomLevel={zoomLevel}
onZoomReset={onZoomReset}
onOpenFeedback={onOpenFeedback}
onOpenSettings={onOpenSettings}
/>
</footer>

View File

@@ -19,4 +19,41 @@ describe('FilterFieldCombobox', () => {
expect(listbox).toBeInTheDocument()
expect(root.contains(listbox)).toBe(false)
})
it('renders the option list inside a dedicated scroll container', () => {
render(
<FilterFieldCombobox
value="status"
fields={Array.from({ length: 20 }, (_, index) => `Field ${index + 1}`)}
onChange={vi.fn()}
/>,
)
fireEvent.focus(screen.getByTestId('filter-field-combobox-input'))
expect(screen.getByTestId('filter-field-combobox-scroll-area')).toHaveClass(
'max-h-60',
'overflow-y-auto',
'overscroll-contain',
)
})
it('stops wheel events from bubbling out of the scroll container', () => {
const onWheel = vi.fn()
render(
<div onWheel={onWheel}>
<FilterFieldCombobox
value="status"
fields={Array.from({ length: 20 }, (_, index) => `Field ${index + 1}`)}
onChange={vi.fn()}
/>
</div>,
)
fireEvent.focus(screen.getByTestId('filter-field-combobox-input'))
fireEvent.wheel(screen.getByTestId('filter-field-combobox-scroll-area'))
expect(onWheel).not.toHaveBeenCalled()
})
})

View File

@@ -20,6 +20,7 @@ import {
} from '../SidebarParts'
import { TypeCustomizePopover } from '../TypeCustomizePopover'
import { useDragRegion } from '../../hooks/useDragRegion'
import { buildTypeEntryMap, getTypeColor, getTypeLightColor } from '../../utils/typeColors'
import { NoteTitleIcon } from '../NoteTitleIcon'
export interface SidebarSectionProps {
@@ -86,7 +87,7 @@ function ViewItem({
const count = useMemo(() => evaluateView(view.definition, entries).length, [view.definition, entries])
return (
<div className="group relative">
<div className="group relative [&>div>span:last-child]:transition-opacity group-hover:[&>div>span:last-child]:opacity-0 group-focus-within:[&>div>span:last-child]:opacity-0">
<NavItem
icon={Funnel}
emoji={view.definition.icon}
@@ -95,7 +96,7 @@ function ViewItem({
isActive={isActive}
onClick={onSelect}
/>
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
{onEditView && (
<button
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
@@ -334,16 +335,46 @@ export function TypesSection({
)
}
const FAVORITE_TYPE_ICON_MAP: Record<string, string> = {
Project: 'wrench',
project: 'wrench',
Experiment: 'flask',
experiment: 'flask',
Responsibility: 'target',
responsibility: 'target',
Procedure: 'arrows-clockwise',
procedure: 'arrows-clockwise',
Person: 'users',
person: 'users',
Event: 'calendar-blank',
event: 'calendar-blank',
Topic: 'tag',
topic: 'tag',
Type: 'stack-simple',
type: 'stack-simple',
}
function getFavoriteIcon(entry: VaultEntry, typeEntryMap: Record<string, VaultEntry>) {
const typeEntry = entry.isA ? typeEntryMap[entry.isA] : undefined
return typeEntry?.icon ?? FAVORITE_TYPE_ICON_MAP[entry.isA ?? ''] ?? 'file-text'
}
function SortableFavoriteItem({
entry,
isActive,
onSelect,
typeEntryMap,
}: {
entry: VaultEntry
isActive: boolean
onSelect: () => void
typeEntryMap: Record<string, VaultEntry>
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: entry.path })
const typeEntry = entry.isA ? typeEntryMap[entry.isA] : undefined
const icon = getFavoriteIcon(entry, typeEntryMap)
const typeColor = getTypeColor(entry.isA ?? null, typeEntry?.color)
const typeLightColor = getTypeLightColor(entry.isA ?? null, typeEntry?.color)
return (
<div
@@ -353,17 +384,36 @@ function SortableFavoriteItem({
{...listeners}
>
<div
className={`flex cursor-pointer select-none items-center gap-1.5 rounded text-[13px] font-normal transition-colors ${isActive ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:bg-accent'}`}
style={{ padding: '4px 16px 4px 28px' }}
className={`group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors ${isActive ? '' : 'hover:bg-accent'}`}
style={{ padding: '6px 8px 6px 16px', borderRadius: 4, gap: 4, ...(isActive ? { background: typeLightColor } : {}) }}
onClick={onSelect}
>
<NoteTitleIcon icon={entry.icon} size={14} />
<span className="truncate">{entry.title}</span>
<div className="flex min-w-0 flex-1 items-center" style={{ gap: 4 }}>
<NoteTitleIcon icon={icon} size={16} color={typeColor} />
<span className="truncate text-[13px] font-medium" style={{ marginLeft: 4, color: isActive ? typeColor : undefined }}>
{entry.title}
</span>
</div>
</div>
</div>
)
}
function sortFavorites(entries: VaultEntry[]) {
return entries
.filter((entry) => entry.favorite && !entry.archived)
.sort((a, b) => (a.favoriteIndex ?? Infinity) - (b.favoriteIndex ?? Infinity))
}
function reorderFavoriteIds(favoriteIds: string[], event: DragEndEvent) {
const { active, over } = event
if (!over || active.id === over.id) return null
const oldIndex = favoriteIds.indexOf(active.id as string)
const newIndex = favoriteIds.indexOf(over.id as string)
if (oldIndex === -1 || newIndex === -1) return null
return arrayMove(favoriteIds, oldIndex, newIndex)
}
export function FavoritesSection({
entries,
selection,
@@ -381,22 +431,14 @@ export function FavoritesSection({
collapsed: boolean
onToggle: () => void
}) {
const favorites = useMemo(
() => entries
.filter((entry) => entry.favorite && !entry.archived)
.sort((a, b) => (a.favoriteIndex ?? Infinity) - (b.favoriteIndex ?? Infinity)),
[entries],
)
const favorites = useMemo(() => sortFavorites(entries), [entries])
const favoriteIds = useMemo(() => favorites.map((entry) => entry.path), [favorites])
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = favoriteIds.indexOf(active.id as string)
const newIndex = favoriteIds.indexOf(over.id as string)
if (oldIndex === -1 || newIndex === -1) return
onReorder?.(arrayMove(favoriteIds, oldIndex, newIndex))
const reordered = reorderFavoriteIds(favoriteIds, event)
if (reordered) onReorder?.(reordered)
}, [favoriteIds, onReorder])
if (favorites.length === 0) return null
@@ -413,6 +455,7 @@ export function FavoritesSection({
key={entry.path}
entry={entry}
isActive={isSelectionActive(selection, { kind: 'entity', entry })}
typeEntryMap={typeEntryMap}
onSelect={() => {
onSelect({ kind: 'filter', filter: 'favorites' })
onSelectNote?.(entry)

View File

@@ -1,7 +1,9 @@
import { Bell, FileText, Package, Settings } from 'lucide-react'
import { Megaphone } from '@phosphor-icons/react'
import type { ClaudeCodeStatus } from '../../hooks/useClaudeCodeStatus'
import type { McpStatus } from '../../hooks/useMcpStatus'
import type { GitRemoteStatus, LastCommitInfo, SyncStatus } from '../../types'
import { Button } from '@/components/ui/button'
import {
ClaudeCodeBadge,
CommitBadge,
@@ -49,6 +51,7 @@ interface StatusBarSecondarySectionProps {
noteCount: number
zoomLevel: number
onZoomReset?: () => void
onOpenFeedback?: () => void
onOpenSettings?: () => void
}
@@ -127,6 +130,7 @@ export function StatusBarSecondarySection({
noteCount,
zoomLevel,
onZoomReset,
onOpenFeedback,
onOpenSettings,
}: StatusBarSecondarySectionProps) {
return (
@@ -148,6 +152,20 @@ export function StatusBarSecondarySection({
{zoomLevel}%
</span>
)}
{onOpenFeedback && (
<Button
type="button"
variant="ghost"
size="xs"
className="h-6 px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground"
onClick={onOpenFeedback}
title="Share feedback"
data-testid="status-feedback"
>
<Megaphone size={14} />
Feedback
</Button>
)}
<span style={DISABLED_STYLE} title="Coming soon">
<Bell size={14} />
</span>

View File

@@ -0,0 +1 @@
export const LAPUTA_GITHUB_ISSUES_URL = 'https://github.com/refactoringhq/laputa-app/issues'

View File

@@ -22,6 +22,7 @@ export interface KeyboardActions {
onToggleRawEditor?: () => void
onToggleInspector?: () => void
onToggleFavorite?: (path: string) => void
onToggleOrganized?: (path: string) => void
onOpenInNewWindow?: () => void
activeTabPathRef: MutableRefObject<string | null>
}
@@ -38,8 +39,10 @@ const VIEW_MODE_KEYS: Record<string, ViewMode> = {
}
function isTextInputFocused(): boolean {
const tag = document.activeElement?.tagName
return tag === 'INPUT' || tag === 'TEXTAREA'
const active = document.activeElement
if (!(active instanceof HTMLElement)) return false
if (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA') return true
return active.isContentEditable || active.closest('[contenteditable="true"]') !== null
}
function isCommandOrCtrlOnly(e: KeyboardEvent): boolean {
@@ -75,7 +78,7 @@ export function createCommandKeyMap(actions: KeyboardActions): ShortcutMap {
s: actions.onSave,
',': actions.onOpenSettings,
d: withActiveTab(activeTabPathRef, (path) => actions.onToggleFavorite?.(path)),
e: withActiveTab(activeTabPathRef, actions.onArchiveNote),
e: withActiveTab(activeTabPathRef, (path) => actions.onToggleOrganized?.(path)),
Backspace: withActiveTab(activeTabPathRef, actions.onDeleteNote),
Delete: withActiveTab(activeTabPathRef, actions.onDeleteNote),
'[': () => actions.onGoBack?.(),
@@ -119,7 +122,8 @@ export function handleCommandKey(e: KeyboardEvent, keyMap: ShortcutMap): boolean
}
export function handleAiPanelKey(e: KeyboardEvent, onToggleAIChat?: () => void): boolean {
if (isCommandShiftOnly(e) === false || e.key.toLowerCase() !== 'l' || onToggleAIChat === undefined) return false
const matchesAiPanelShortcut = e.code === 'KeyL' || e.key.toLowerCase() === 'l'
if (isCommandShiftOnly(e) === false || matchesAiPanelShortcut === false || onToggleAIChat === undefined) return false
e.preventDefault()
onToggleAIChat()
return true

View File

@@ -45,7 +45,7 @@ export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
execute: () => { if (activeTabPath) onDeleteNote(activeTabPath) },
},
{
id: 'archive-note', label: isArchived ? 'Unarchive Note' : 'Archive Note', group: 'Note', shortcut: '⌘E',
id: 'archive-note', label: isArchived ? 'Unarchive Note' : 'Archive Note', group: 'Note',
keywords: ['archive'], enabled: hasActiveNote,
execute: () => { if (activeTabPath) (isArchived ? onUnarchiveNote : onArchiveNote)(activeTabPath) },
},
@@ -62,7 +62,7 @@ export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
execute: () => { if (activeTabPath) onToggleFavorite?.(activeTabPath) },
},
{
id: 'toggle-organized', label: isOrganized ? 'Mark as Unorganized' : 'Mark as Organized', group: 'Note',
id: 'toggle-organized', label: isOrganized ? 'Mark as Unorganized' : 'Mark as Organized', group: 'Note', shortcut: '⌘E',
keywords: ['organized', 'inbox', 'triage', 'done'],
enabled: hasActiveNote && !!onToggleOrganized,
execute: () => { if (activeTabPath) onToggleOrganized?.(activeTabPath) },

View File

@@ -5,6 +5,7 @@ interface SettingsCommandsConfig {
vaultCount?: number
isGettingStartedHidden?: boolean
onOpenSettings: () => void
onOpenFeedback?: () => void
onOpenVault?: () => void
onRemoveActiveVault?: () => void
onRestoreGettingStarted?: () => void
@@ -17,12 +18,13 @@ interface SettingsCommandsConfig {
export function buildSettingsCommands(config: SettingsCommandsConfig): CommandAction[] {
const {
mcpStatus, vaultCount, isGettingStartedHidden,
onOpenSettings, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted,
onOpenSettings, onOpenFeedback, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted,
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
} = config
return [
{ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings },
{ id: 'give-feedback', label: 'Give Feedback', group: 'Settings', keywords: ['feedback', 'issue', 'bug', 'github', 'report'], enabled: !!onOpenFeedback, execute: () => onOpenFeedback?.() },
{ id: 'open-vault', label: 'Open Vault…', group: 'Settings', keywords: ['vault', 'folder', 'switch', 'open', 'workspace'], enabled: true, execute: () => onOpenVault?.() },
{ id: 'remove-vault', label: 'Remove Vault from List', group: 'Settings', keywords: ['vault', 'remove', 'disconnect', 'hide'], enabled: (vaultCount ?? 0) > 1 && !!onRemoveActiveVault, execute: () => onRemoveActiveVault?.() },
{ id: 'restore-getting-started', label: 'Restore Getting Started Vault', group: 'Settings', keywords: ['vault', 'restore', 'demo', 'getting started', 'reset'], enabled: !!isGettingStartedHidden && !!onRestoreGettingStarted, execute: () => onRestoreGettingStarted?.() },

View File

@@ -23,6 +23,7 @@ interface AppCommandsConfig {
onCreateNoteOfType: (type: string) => void
onSave: () => void
onOpenSettings: () => void
onOpenFeedback?: () => void
onDeleteNote: (path: string) => void
onArchiveNote: (path: string) => void
onUnarchiveNote: (path: string) => void
@@ -115,6 +116,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onToggleRawEditor: config.onToggleRawEditor,
onToggleInspector: config.onToggleInspector,
onToggleFavorite: config.onToggleFavorite,
onToggleOrganized: config.onToggleOrganized,
onOpenInNewWindow: config.onOpenInNewWindow,
activeTabPathRef: config.activeTabPathRef,
})
@@ -138,6 +140,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onToggleRawEditor: config.onToggleRawEditor,
onToggleDiff: config.onToggleDiff,
onToggleAIChat: config.onToggleAIChat,
onToggleOrganized: config.onToggleOrganized,
onGoBack: config.onGoBack,
onGoForward: config.onGoForward,
onCheckForUpdates: config.onCheckForUpdates,
@@ -169,6 +172,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onCreateNoteOfType: config.onCreateNoteOfType,
onSave: config.onSave,
onOpenSettings: config.onOpenSettings,
onOpenFeedback: config.onOpenFeedback,
onDeleteNote: config.onDeleteNote,
onArchiveNote: config.onArchiveNote,
onUnarchiveNote: config.onUnarchiveNote,

View File

@@ -2,9 +2,21 @@ import { describe, it, expect, vi, afterEach } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useAppKeyboard } from './useAppKeyboard'
function fireKey(key: string, mods: { altKey?: boolean; metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean } = {}) {
function fireKey(
key: string,
mods: { altKey?: boolean; metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; code?: string } = {},
) {
fireKeyOnTarget(window, key, mods)
}
function fireKeyOnTarget(
target: EventTarget,
key: string,
mods: { altKey?: boolean; metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; code?: string } = {},
) {
const event = new KeyboardEvent('keydown', {
key,
code: mods.code,
altKey: mods.altKey ?? false,
metaKey: mods.metaKey ?? false,
ctrlKey: mods.ctrlKey ?? false,
@@ -12,7 +24,7 @@ function fireKey(key: string, mods: { altKey?: boolean; metaKey?: boolean; ctrlK
bubbles: true,
cancelable: true,
})
window.dispatchEvent(event)
target.dispatchEvent(event)
}
function makeActions() {
@@ -26,6 +38,7 @@ function makeActions() {
onOpenSettings: vi.fn(),
onDeleteNote: vi.fn(),
onArchiveNote: vi.fn(),
onToggleOrganized: vi.fn(),
onSetViewMode: vi.fn(),
onZoomIn: vi.fn(),
onZoomOut: vi.fn(),
@@ -87,6 +100,14 @@ describe('useAppKeyboard', () => {
expect(actions.onToggleFavorite).toHaveBeenCalledWith('/vault/test.md')
})
it('Cmd+E triggers toggle organized on active note, not archive', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('e', { metaKey: true })
expect(actions.onToggleOrganized).toHaveBeenCalledWith('/vault/test.md')
expect(actions.onArchiveNote).not.toHaveBeenCalled()
})
it('Cmd+J triggers open daily note', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
@@ -130,6 +151,14 @@ describe('useAppKeyboard', () => {
try { fn() } finally { document.body.removeChild(input) }
}
function withFocusedContentEditable(fn: (editable: HTMLDivElement) => void) {
const editable = document.createElement('div')
editable.setAttribute('contenteditable', 'true')
document.body.appendChild(editable)
editable.focus()
try { fn(editable) } finally { document.body.removeChild(editable) }
}
it('Cmd+Backspace does not delete note when text input is focused', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
@@ -139,6 +168,15 @@ describe('useAppKeyboard', () => {
})
})
it('Cmd+Backspace does not delete note when contenteditable is focused', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
withFocusedContentEditable((editable) => {
fireKeyOnTarget(editable, 'Backspace', { metaKey: true })
expect(actions.onDeleteNote).not.toHaveBeenCalled()
})
})
it('Cmd+Backspace deletes note when no text input is focused', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
@@ -201,6 +239,25 @@ describe('useAppKeyboard', () => {
})
})
it('Cmd+Shift+L works when editor stops propagation', () => {
const actions = makeActions()
const onToggleAIChat = vi.fn()
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
withFocusedContentEditable((editable) => {
editable.addEventListener('keydown', (event) => event.stopPropagation())
fireKeyOnTarget(editable, 'l', { metaKey: true, shiftKey: true })
expect(onToggleAIChat).toHaveBeenCalled()
})
})
it('Cmd+Shift+L matches by physical key code when the localized key differs', () => {
const actions = makeActions()
const onToggleAIChat = vi.fn()
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
fireKey('¬', { code: 'KeyL', metaKey: true, shiftKey: true })
expect(onToggleAIChat).toHaveBeenCalled()
})
it('Ctrl+Shift+L does not trigger toggle AI chat', () => {
const actions = makeActions()
const onToggleAIChat = vi.fn()

View File

@@ -14,7 +14,7 @@ export function useAppKeyboard(actions: KeyboardActions) {
onKeyDown(event)
}
window.addEventListener('keydown', handleWindowKeyDown)
return () => window.removeEventListener('keydown', handleWindowKeyDown)
window.addEventListener('keydown', handleWindowKeyDown, true)
return () => window.removeEventListener('keydown', handleWindowKeyDown, true)
}, [])
}

View File

@@ -16,6 +16,7 @@ function makeConfig(overrides: Record<string, unknown> = {}) {
onDeleteNote: vi.fn(),
onArchiveNote: vi.fn(),
onUnarchiveNote: vi.fn(),
onToggleOrganized: vi.fn(),
onCommitPush: vi.fn(),
onResolveConflicts: vi.fn(),
onSetViewMode: vi.fn(),
@@ -192,6 +193,26 @@ describe('useCommandRegistry', () => {
const cmd = findCommand(result.current, 'customize-inbox-columns')
expect(cmd!.enabled).toBe(false)
})
it('shows Cmd+E on toggle organized and removes it from archive note', () => {
const config = makeConfig()
const { result } = renderHook(() => useCommandRegistry(config))
expect(findCommand(result.current, 'toggle-organized')?.shortcut).toBe('⌘E')
expect(findCommand(result.current, 'archive-note')?.shortcut).toBeUndefined()
})
it('includes Give Feedback in the Settings group when available', () => {
const onOpenFeedback = vi.fn()
const config = makeConfig({ onOpenFeedback })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'give-feedback')
expect(cmd).toBeDefined()
expect(cmd!.group).toBe('Settings')
expect(cmd!.enabled).toBe(true)
cmd!.execute()
expect(onOpenFeedback).toHaveBeenCalledOnce()
})
})
describe('pluralizeType', () => {

View File

@@ -39,6 +39,7 @@ interface CommandRegistryConfig {
onCreateNoteOfType: (type: string) => void
onSave: () => void
onOpenSettings: () => void
onOpenFeedback?: () => void
onOpenVault?: () => void
onCreateType?: () => void
onDeleteNote: (path: string) => void
@@ -76,7 +77,7 @@ interface CommandRegistryConfig {
export function useCommandRegistry(config: CommandRegistryConfig): import('./commands/types').CommandAction[] {
const {
activeTabPath, entries, modifiedCount,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings, onOpenFeedback,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
activeNoteModified,
@@ -124,14 +125,14 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
}),
...buildSettingsCommands({
mcpStatus, vaultCount, isGettingStartedHidden,
onOpenSettings, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted,
onOpenSettings, onOpenFeedback, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted,
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
}),
...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
...buildFilterCommands({ isSectionGroup, noteListFilter, onSetNoteListFilter }),
], [
hasActiveNote, activeTabPath, isArchived, modifiedCount, activeNoteModified,
onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, onSave, onOpenSettings,
onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, onSave, onOpenSettings, onOpenFeedback,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
onCheckForUpdates,

View File

@@ -15,6 +15,7 @@ function makeHandlers(): MenuEventHandlers {
onZoomIn: vi.fn(),
onZoomOut: vi.fn(),
onZoomReset: vi.fn(),
onToggleOrganized: vi.fn(),
onArchiveNote: vi.fn(),
onDeleteNote: vi.fn(),
onSearch: vi.fn(),
@@ -143,6 +144,19 @@ describe('dispatchMenuEvent', () => {
expect(h.onArchiveNote).not.toHaveBeenCalled()
})
it('note-toggle-organized triggers organized toggle on active tab', () => {
const h = makeHandlers()
dispatchMenuEvent('note-toggle-organized', h)
expect(h.onToggleOrganized).toHaveBeenCalledWith('/vault/test.md')
})
it('note-toggle-organized does nothing when no active tab', () => {
const h = makeHandlers()
h.activeTabPathRef = { current: null }
dispatchMenuEvent('note-toggle-organized', h)
expect(h.onToggleOrganized).not.toHaveBeenCalled()
})
it('note-delete triggers delete on active tab', () => {
const h = makeHandlers()
dispatchMenuEvent('note-delete', h)

View File

@@ -16,6 +16,7 @@ export interface MenuEventHandlers {
onZoomIn: () => void
onZoomOut: () => void
onZoomReset: () => void
onToggleOrganized?: (path: string) => void
onArchiveNote: (path: string) => void
onDeleteNote: (path: string) => void
onSearch: () => void
@@ -106,7 +107,8 @@ const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
function dispatchActiveTabEvent(id: string, h: MenuEventHandlers): boolean {
const path = h.activeTabPathRef.current
if (!path) return id === 'note-archive' || id === 'note-delete'
if (!path) return id === 'note-toggle-organized' || id === 'note-archive' || id === 'note-delete'
if (id === 'note-toggle-organized') { h.onToggleOrganized?.(path); return true }
if (id === 'note-archive') { h.onArchiveNote(path); return true }
if (id === 'note-delete') { h.onDeleteNote(path); return true }
return false

View File

@@ -641,4 +641,3 @@ describe('useNoteActions hook', () => {
})
})
})

View File

@@ -0,0 +1,58 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useNoteActions, type NoteActionsConfig } from './useNoteActions'
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
vi.mock('../mock-tauri', () => ({
isTauri: vi.fn(() => false),
addMockEntry: vi.fn(),
updateMockContent: vi.fn(),
trackMockChange: vi.fn(),
mockInvoke: vi.fn().mockResolvedValue(''),
}))
vi.mock('./mockFrontmatterHelpers', () => ({
updateMockFrontmatter: vi.fn().mockReturnValue('---\nupdated: true\n---\n'),
deleteMockFrontmatterProperty: vi.fn().mockReturnValue('---\n---\n'),
}))
function makeConfig(onFrontmatterPersisted: () => void): NoteActionsConfig {
return {
addEntry: vi.fn(),
removeEntry: vi.fn(),
entries: [],
setToastMessage: vi.fn(),
updateEntry: vi.fn(),
vaultPath: '/test/vault',
onFrontmatterPersisted,
}
}
describe('useNoteActions frontmatter persistence', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it.each([
{
label: 'update',
run: async (result: ReturnType<typeof renderHook<typeof useNoteActions>>['result']) => {
await result.current.handleUpdateFrontmatter('/vault/note.md', '_list_properties_display', ['Owner', 'Status'])
},
},
{
label: 'delete',
run: async (result: ReturnType<typeof renderHook<typeof useNoteActions>>['result']) => {
await result.current.handleDeleteProperty('/vault/note.md', 'status')
},
},
])('notifies after a frontmatter $label completes', async ({ run }) => {
const onFrontmatterPersisted = vi.fn()
const { result } = renderHook(() => useNoteActions(makeConfig(onFrontmatterPersisted)))
await act(async () => {
await run(result)
})
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
})
})

View File

@@ -27,6 +27,8 @@ export interface NoteActionsConfig {
replaceEntry?: (oldPath: string, patch: Partial<VaultEntry> & { path: string }) => void
/** Called after frontmatter is written to disk — used for live-reloading theme CSS vars. */
onFrontmatterContentChanged?: (path: string, content: string) => void
/** Called after a frontmatter mutation is fully persisted, including follow-up renames. */
onFrontmatterPersisted?: () => void
}
function isTitleKey(key: string): boolean {
@@ -44,6 +46,12 @@ interface TitleRenameDeps {
updateTabContent: (path: string, content: string) => void
}
function applyFrontmatterCallbacks(config: NoteActionsConfig, path: string, newContent: string | undefined): boolean {
if (!newContent) return false
config.onFrontmatterContentChanged?.(path, newContent)
return true
}
async function renameAfterTitleChange(path: string, newTitle: string, deps: TitleRenameDeps): Promise<void> {
const oldTitle = deps.tabsRef.current.find(t => t.entry.path === path)?.entry.title
const result = await performRename(path, newTitle, deps.vaultPath, oldTitle)
@@ -71,6 +79,20 @@ function navigateWikilink(entries: VaultEntry[], target: string, selectNote: (e:
else console.warn(`Navigation target not found: ${target}`)
}
async function maybeRenameAfterFrontmatterUpdate(
path: string,
key: string,
value: FrontmatterValue,
deps: TitleRenameDeps,
): Promise<void> {
if (!shouldRenameOnTitleUpdate(key, value)) return
try {
await renameAfterTitleChange(path, value, deps)
} catch (err) {
console.error('Failed to rename note after title change:', err)
}
}
export function useNoteActions(config: NoteActionsConfig) {
const { entries, setToastMessage, updateEntry } = config
const tabMgmt = useTabManagement()
@@ -108,25 +130,22 @@ export function useNoteActions(config: NoteActionsConfig) {
createTypeEntrySilent: creation.createTypeEntrySilent,
handleUpdateFrontmatter: useCallback(async (path: string, key: string, value: FrontmatterValue, options?: FrontmatterOpOptions) => {
const newContent = await runFrontmatterOp('update', path, key, value, options)
if (newContent) config.onFrontmatterContentChanged?.(path, newContent)
if (shouldRenameOnTitleUpdate(key, value)) {
try {
await renameAfterTitleChange(path, value, {
vaultPath: config.vaultPath, tabsRef: rename.tabsRef, replaceEntry: config.replaceEntry,
setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent,
})
} catch (err) {
console.error('Failed to rename note after title change:', err)
}
}
if (!applyFrontmatterCallbacks(config, path, newContent)) return
await maybeRenameAfterFrontmatterUpdate(path, key, value, {
vaultPath: config.vaultPath, tabsRef: rename.tabsRef, replaceEntry: config.replaceEntry,
setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent,
})
config.onFrontmatterPersisted?.()
}, [runFrontmatterOp, config, rename.tabsRef, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent]),
handleDeleteProperty: useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => {
const newContent = await runFrontmatterOp('delete', path, key, undefined, options)
if (newContent) config.onFrontmatterContentChanged?.(path, newContent)
if (!applyFrontmatterCallbacks(config, path, newContent)) return
config.onFrontmatterPersisted?.()
}, [runFrontmatterOp, config]),
handleAddProperty: useCallback(async (path: string, key: string, value: FrontmatterValue) => {
const newContent = await runFrontmatterOp('update', path, key, value)
if (newContent) config.onFrontmatterContentChanged?.(path, newContent)
if (!applyFrontmatterCallbacks(config, path, newContent)) return
config.onFrontmatterPersisted?.()
}, [runFrontmatterOp, config]),
handleRenameNote: rename.handleRenameNote,
}

View File

@@ -8,9 +8,10 @@ test.describe('AI panel shortcut', () => {
await expect(page.locator('[data-testid="note-list-container"]')).toBeVisible({ timeout: 5_000 })
})
test('Cmd/Ctrl+Shift+L opens the AI panel', async ({ page }) => {
test('Cmd+Shift+L opens the AI panel from the editor', async ({ page }) => {
await page.locator('.app__note-list .cursor-pointer').first().click()
await sendShortcut(page, 'L', ['Control', 'Shift'])
await page.locator('.bn-editor').click()
await sendShortcut(page, 'L', ['Meta', 'Shift'])
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3_000 })
await expect(page.getByTitle('Close AI panel')).toBeVisible()
})