From 05156ca793feffbde96b5baf375d67c8e781998f Mon Sep 17 00:00:00 2001 From: Brian Lee Date: Fri, 24 Apr 2026 15:04:08 +0900 Subject: [PATCH] feat: add inbox auto-advance setting --- src-tauri/src/settings.rs | 10 ++++- src/App.test.tsx | 40 +++++++++++++++++++ src/App.tsx | 32 +++++++++++++-- src/components/SettingsPanel.test.tsx | 21 ++++++++++ src/components/SettingsPanel.tsx | 25 +++++++++++- src/hooks/useSettings.test.ts | 3 ++ src/hooks/useSettings.ts | 1 + src/mock-tauri/mock-handlers.coverage.test.ts | 2 + src/mock-tauri/mock-handlers.ts | 2 + src/types.ts | 1 + 10 files changed, 132 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 142d0fd9..4705a70b 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -11,6 +11,7 @@ pub struct Settings { pub autogit_enabled: Option, pub autogit_idle_threshold_seconds: Option, pub autogit_inactive_threshold_seconds: Option, + pub auto_advance_inbox_after_organize: Option, pub telemetry_consent: Option, pub crash_reporting_enabled: Option, pub analytics_enabled: Option, @@ -62,6 +63,7 @@ fn normalize_settings(settings: Settings) -> Settings { autogit_inactive_threshold_seconds: normalize_optional_positive_u32( settings.autogit_inactive_threshold_seconds, ), + auto_advance_inbox_after_organize: settings.auto_advance_inbox_after_organize, telemetry_consent: settings.telemetry_consent, crash_reporting_enabled: settings.crash_reporting_enabled, analytics_enabled: settings.analytics_enabled, @@ -172,6 +174,7 @@ mod tests { Option, Option, Option, + Option, Option<&'a str>, Option<&'a str>, Option, @@ -184,6 +187,7 @@ mod tests { settings.autogit_enabled, settings.autogit_idle_threshold_seconds, settings.autogit_inactive_threshold_seconds, + settings.auto_advance_inbox_after_organize, settings.telemetry_consent, settings.crash_reporting_enabled, settings.analytics_enabled, @@ -197,7 +201,7 @@ mod tests { fn assert_empty_settings(settings: &Settings) { assert_eq!( settings_snapshot(settings), - (None, None, None, None, None, None, None, None, None, None, None) + (None, None, None, None, None, None, None, None, None, None, None, None) ); } @@ -234,6 +238,7 @@ mod tests { autogit_enabled: Some(true), autogit_idle_threshold_seconds: Some(90), autogit_inactive_threshold_seconds: Some(30), + auto_advance_inbox_after_organize: Some(true), telemetry_consent: Some(true), crash_reporting_enabled: Some(true), analytics_enabled: Some(false), @@ -262,6 +267,7 @@ mod tests { autogit_enabled: Some(true), autogit_idle_threshold_seconds: Some(90), autogit_inactive_threshold_seconds: Some(30), + auto_advance_inbox_after_organize: Some(true), release_channel: Some("alpha".to_string()), initial_h1_auto_rename_enabled: Some(false), default_ai_agent: Some("codex".to_string()), @@ -271,6 +277,7 @@ mod tests { assert_eq!(loaded.autogit_enabled, Some(true)); assert_eq!(loaded.autogit_idle_threshold_seconds, Some(90)); assert_eq!(loaded.autogit_inactive_threshold_seconds, Some(30)); + assert_eq!(loaded.auto_advance_inbox_after_organize, Some(true)); assert_eq!(loaded.release_channel.as_deref(), Some("alpha")); assert_eq!(loaded.initial_h1_auto_rename_enabled, Some(false)); assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex")); @@ -383,6 +390,7 @@ mod tests { None, None, None, + None, Some(true), Some(true), Some(false), diff --git a/src/App.test.tsx b/src/App.test.tsx index e67e0d37..843f2bf6 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -241,6 +241,7 @@ function resetMockCommandResults() { get_file_history: [], get_settings: { auto_pull_interval_minutes: null, + auto_advance_inbox_after_organize: null, telemetry_consent: true, crash_reporting_enabled: null, analytics_enabled: null, @@ -267,6 +268,7 @@ vi.mock('./mock-tauri', () => ({ mockInvoke: vi.fn(async (cmd: string, args?: unknown) => resolveMockCommandResult(cmd, args)), addMockEntry: vi.fn(), updateMockContent: vi.fn(), + trackMockChange: vi.fn(), })) // Mock ai-chat utilities @@ -740,6 +742,44 @@ describe('App', () => { }) }) + it('auto-advances to the next inbox item after organizing when the setting is enabled', async () => { + configureNeighborhoodVault() + mockCommandResults.get_settings = { + auto_pull_interval_minutes: null, + auto_advance_inbox_after_organize: true, + telemetry_consent: true, + crash_reporting_enabled: null, + analytics_enabled: null, + anonymous_id: null, + release_channel: null, + } + + render() + + const noteListContainer = await screen.findByTestId('note-list-container') + await waitFor(() => { + expect(getHeaderForNoteList(noteListContainer)).toHaveTextContent('Inbox') + }) + + await act(async () => { + fireEvent.click(within(noteListContainer).getByText('Alpha')) + await Promise.resolve() + }) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Set note as organized' })).toBeInTheDocument() + }) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Set note as organized' })) + await Promise.resolve() + }) + + await waitFor(() => { + expect(window.__laputaTest?.activeTabPath).toBe('/vault/beta.md') + }) + }) + it('renders status bar', async () => { render() // StatusBar should be present diff --git a/src/App.tsx b/src/App.tsx index 1f3e68cf..a4df14e7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -120,6 +120,12 @@ declare global { const DEFAULT_SELECTION: SidebarSelection = INBOX_SELECTION +function getNextVisibleInboxEntry(entries: VaultEntry[], currentPath: string): VaultEntry | null { + const currentIndex = entries.findIndex((entry) => entry.path === currentPath) + if (currentIndex < 0) return null + return entries[currentIndex + 1] ?? null +} + function shouldPreferOnboardingVaultPath( onboardingState: { status: string; vaultPath?: string }, vaults: Array<{ path: string }>, @@ -875,6 +881,7 @@ function App() { useEffect(() => { window.__laputaTest = { ...window.__laputaTest, + activeTabPath: notes.activeTabPath, seedAutoGitSavedChange, } @@ -883,7 +890,7 @@ function App() { delete window.__laputaTest.seedAutoGitSavedChange } } - }, [seedAutoGitSavedChange]) + }, [notes.activeTabPath, seedAutoGitSavedChange]) const entryActions = useEntryActions({ entries: vault.entries, updateEntry: vault.updateEntry, @@ -1203,7 +1210,26 @@ function App() { const entry = vault.entries.find((candidate) => candidate.path === notes.activeTabPath) return hasNoteIconValue(entry?.icon) }, [notes.activeTabPath, vault.entries]) - const toggleOrganizedCommand = explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined + const handleToggleOrganizedWithInboxAdvance = useCallback(async (path: string) => { + const entry = vault.entries.find((candidate) => candidate.path === path) + if (!entry) return + + const shouldAutoAdvance = settings.auto_advance_inbox_after_organize === true + && !entry.organized + && notes.activeTabPath === path + && effectiveSelection.kind === 'filter' + && effectiveSelection.filter === 'inbox' + const nextVisibleInboxEntry = shouldAutoAdvance + ? getNextVisibleInboxEntry(visibleNotesRef.current, path) + : null + + await entryActions.handleToggleOrganized(path) + + if (nextVisibleInboxEntry) { + void notes.handleSelectNote(nextVisibleInboxEntry) + } + }, [effectiveSelection, entryActions, notes, settings.auto_advance_inbox_after_organize, vault.entries]) + const toggleOrganizedCommand = explicitOrganizationEnabled ? handleToggleOrganizedWithInboxAdvance : undefined const canCustomizeNoteListColumns = useMemo(() => ( effectiveSelection.kind === 'view' || ( @@ -1437,7 +1463,7 @@ function App() { noteList={aiNoteList} noteListFilter={aiNoteListFilter} onToggleFavorite={activeDeletedFile ? undefined : entryActions.handleToggleFavorite} - onToggleOrganized={activeDeletedFile || !explicitOrganizationEnabled ? undefined : entryActions.handleToggleOrganized} + onToggleOrganized={activeDeletedFile || !explicitOrganizationEnabled ? undefined : toggleOrganizedCommand} onDeleteNote={activeDeletedFile ? undefined : deleteActions.handleDeleteNote} onArchiveNote={activeDeletedFile ? undefined : entryActions.handleArchiveNote} onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote} diff --git a/src/components/SettingsPanel.test.tsx b/src/components/SettingsPanel.test.tsx index 45923fc2..2634a941 100644 --- a/src/components/SettingsPanel.test.tsx +++ b/src/components/SettingsPanel.test.tsx @@ -8,6 +8,7 @@ const emptySettings: Settings = { autogit_enabled: null, autogit_idle_threshold_seconds: null, autogit_inactive_threshold_seconds: null, + auto_advance_inbox_after_organize: null, telemetry_consent: null, crash_reporting_enabled: null, analytics_enabled: null, @@ -138,6 +139,13 @@ describe('SettingsPanel', () => { expect(screen.getByRole('switch', { name: 'Organize notes explicitly' })).toHaveAttribute('aria-checked', 'true') }) + it('defaults auto-advance to the next inbox item to off', () => { + render( + + ) + expect(screen.getByRole('switch', { name: 'Auto-advance to next Inbox item' })).toHaveAttribute('aria-checked', 'false') + }) + it('defaults the initial H1 auto-rename switch to on', () => { render( @@ -220,6 +228,19 @@ describe('SettingsPanel', () => { expect(onSaveExplicitOrganization).toHaveBeenCalledWith(false) }) + it('saves the auto-advance inbox preference when toggled on', () => { + render( + + ) + + fireEvent.click(screen.getByRole('switch', { name: 'Auto-advance to next Inbox item' })) + fireEvent.click(screen.getByTestId('settings-save')) + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + auto_advance_inbox_after_organize: true, + })) + }) + it('calls onClose when Cancel is clicked', () => { render( diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 75a5ab1d..db6ed401 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -47,6 +47,7 @@ interface SettingsDraft { autoGitEnabled: boolean autoGitIdleThresholdSeconds: number autoGitInactiveThresholdSeconds: number + autoAdvanceInboxAfterOrganize: boolean defaultAiAgent: AiAgentId releaseChannel: ReleaseChannel initialH1AutoRename: boolean @@ -65,6 +66,8 @@ interface SettingsBodyProps { setAutoGitIdleThresholdSeconds: (value: number) => void autoGitInactiveThresholdSeconds: number setAutoGitInactiveThresholdSeconds: (value: number) => void + autoAdvanceInboxAfterOrganize: boolean + setAutoAdvanceInboxAfterOrganize: (value: boolean) => void aiAgentsStatus: AiAgentsStatus defaultAiAgent: AiAgentId setDefaultAiAgent: (value: AiAgentId) => void @@ -103,6 +106,7 @@ function createSettingsDraft( settings.autogit_inactive_threshold_seconds, DEFAULT_AUTOGIT_INACTIVE_THRESHOLD_SECONDS, ), + autoAdvanceInboxAfterOrganize: settings.auto_advance_inbox_after_organize ?? false, defaultAiAgent: resolveDefaultAiAgent(settings.default_ai_agent), releaseChannel: normalizeReleaseChannel(settings.release_channel), initialH1AutoRename: settings.initial_h1_auto_rename_enabled ?? true, @@ -131,6 +135,7 @@ function buildSettingsFromDraft(settings: Settings, draft: SettingsDraft): Setti autogit_enabled: draft.autoGitEnabled, autogit_idle_threshold_seconds: draft.autoGitIdleThresholdSeconds, autogit_inactive_threshold_seconds: draft.autoGitInactiveThresholdSeconds, + auto_advance_inbox_after_organize: draft.autoAdvanceInboxAfterOrganize, telemetry_consent: resolveTelemetryConsent(settings, draft), crash_reporting_enabled: draft.crashReporting, analytics_enabled: draft.analytics, @@ -267,6 +272,8 @@ function SettingsPanelInner({ setAutoGitIdleThresholdSeconds={(value) => updateDraft('autoGitIdleThresholdSeconds', value)} autoGitInactiveThresholdSeconds={draft.autoGitInactiveThresholdSeconds} setAutoGitInactiveThresholdSeconds={(value) => updateDraft('autoGitInactiveThresholdSeconds', value)} + autoAdvanceInboxAfterOrganize={draft.autoAdvanceInboxAfterOrganize} + setAutoAdvanceInboxAfterOrganize={(value) => updateDraft('autoAdvanceInboxAfterOrganize', value)} aiAgentsStatus={aiAgentsStatus} defaultAiAgent={draft.defaultAiAgent} setDefaultAiAgent={(value) => updateDraft('defaultAiAgent', value)} @@ -317,6 +324,8 @@ function SettingsBody({ setAutoGitIdleThresholdSeconds, autoGitInactiveThresholdSeconds, setAutoGitInactiveThresholdSeconds, + autoAdvanceInboxAfterOrganize, + setAutoAdvanceInboxAfterOrganize, aiAgentsStatus, defaultAiAgent, setDefaultAiAgent, @@ -373,6 +382,8 @@ function SettingsBody({ @@ -707,15 +718,19 @@ function LabeledNumberInput({ function OrganizationWorkflowSection({ checked, onChange, + autoAdvanceInboxAfterOrganize, + onChangeAutoAdvanceInboxAfterOrganize, }: { checked: boolean onChange: (value: boolean) => void + autoAdvanceInboxAfterOrganize: boolean + onChangeAutoAdvanceInboxAfterOrganize: (value: boolean) => void }) { return ( <> + + ) } diff --git a/src/hooks/useSettings.test.ts b/src/hooks/useSettings.test.ts index f5c66d2e..8816d80f 100644 --- a/src/hooks/useSettings.test.ts +++ b/src/hooks/useSettings.test.ts @@ -8,6 +8,7 @@ const defaultSettings: Settings = { autogit_enabled: null, autogit_idle_threshold_seconds: null, autogit_inactive_threshold_seconds: null, + auto_advance_inbox_after_organize: null, telemetry_consent: null, crash_reporting_enabled: null, analytics_enabled: null, @@ -21,6 +22,7 @@ const savedSettings: Settings = { autogit_enabled: true, autogit_idle_threshold_seconds: 90, autogit_inactive_threshold_seconds: 30, + auto_advance_inbox_after_organize: true, telemetry_consent: null, crash_reporting_enabled: null, analytics_enabled: null, @@ -103,6 +105,7 @@ describe('useSettings', () => { autogit_enabled: false, autogit_idle_threshold_seconds: 120, autogit_inactive_threshold_seconds: 45, + auto_advance_inbox_after_organize: false, telemetry_consent: null, crash_reporting_enabled: null, analytics_enabled: null, diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 3202f7b2..0a4bb046 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -14,6 +14,7 @@ const EMPTY_SETTINGS: Settings = { autogit_enabled: null, autogit_idle_threshold_seconds: null, autogit_inactive_threshold_seconds: null, + auto_advance_inbox_after_organize: null, telemetry_consent: null, crash_reporting_enabled: null, analytics_enabled: null, diff --git a/src/mock-tauri/mock-handlers.coverage.test.ts b/src/mock-tauri/mock-handlers.coverage.test.ts index 8f8332fb..0db97da1 100644 --- a/src/mock-tauri/mock-handlers.coverage.test.ts +++ b/src/mock-tauri/mock-handlers.coverage.test.ts @@ -146,6 +146,7 @@ describe('mockHandlers coverage', () => { autogit_enabled: true, autogit_idle_threshold_seconds: undefined, autogit_inactive_threshold_seconds: undefined, + auto_advance_inbox_after_organize: true, telemetry_consent: true, crash_reporting_enabled: false, analytics_enabled: true, @@ -160,6 +161,7 @@ describe('mockHandlers coverage', () => { autogit_enabled: true, autogit_idle_threshold_seconds: 90, autogit_inactive_threshold_seconds: 30, + auto_advance_inbox_after_organize: true, telemetry_consent: true, crash_reporting_enabled: false, analytics_enabled: true, diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index 09ab9e87..4e7d4308 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -105,6 +105,7 @@ let mockSettings: Settings = { autogit_enabled: false, autogit_idle_threshold_seconds: 90, autogit_inactive_threshold_seconds: 30, + auto_advance_inbox_after_organize: false, telemetry_consent: false, crash_reporting_enabled: null, analytics_enabled: null, @@ -409,6 +410,7 @@ export const mockHandlers: Record any> = { autogit_enabled: s.autogit_enabled ?? false, autogit_idle_threshold_seconds: s.autogit_idle_threshold_seconds ?? 90, autogit_inactive_threshold_seconds: s.autogit_inactive_threshold_seconds ?? 30, + auto_advance_inbox_after_organize: s.auto_advance_inbox_after_organize ?? false, telemetry_consent: s.telemetry_consent, crash_reporting_enabled: s.crash_reporting_enabled, analytics_enabled: s.analytics_enabled, diff --git a/src/types.ts b/src/types.ts index d69a2bc8..3c698cab 100644 --- a/src/types.ts +++ b/src/types.ts @@ -83,6 +83,7 @@ export interface Settings { autogit_enabled?: boolean | null autogit_idle_threshold_seconds?: number | null autogit_inactive_threshold_seconds?: number | null + auto_advance_inbox_after_organize?: boolean | null telemetry_consent: boolean | null crash_reporting_enabled: boolean | null analytics_enabled: boolean | null