feat: add inbox auto-advance setting

This commit is contained in:
Brian Lee
2026-04-24 15:04:08 +09:00
parent 33d7404d6c
commit 05156ca793
10 changed files with 132 additions and 5 deletions

View File

@@ -11,6 +11,7 @@ pub struct Settings {
pub autogit_enabled: Option<bool>,
pub autogit_idle_threshold_seconds: Option<u32>,
pub autogit_inactive_threshold_seconds: Option<u32>,
pub auto_advance_inbox_after_organize: Option<bool>,
pub telemetry_consent: Option<bool>,
pub crash_reporting_enabled: Option<bool>,
pub analytics_enabled: Option<bool>,
@@ -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<bool>,
Option<bool>,
Option<bool>,
Option<bool>,
Option<&'a str>,
Option<&'a str>,
Option<bool>,
@@ -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),

View File

@@ -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(<App />)
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(<App />)
// StatusBar should be present

View File

@@ -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}

View File

@@ -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(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
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(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
@@ -220,6 +228,19 @@ describe('SettingsPanel', () => {
expect(onSaveExplicitOrganization).toHaveBeenCalledWith(false)
})
it('saves the auto-advance inbox preference when toggled on', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
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(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />

View File

@@ -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({
<OrganizationWorkflowSection
checked={explicitOrganization}
onChange={setExplicitOrganization}
autoAdvanceInboxAfterOrganize={autoAdvanceInboxAfterOrganize}
onChangeAutoAdvanceInboxAfterOrganize={setAutoAdvanceInboxAfterOrganize}
/>
</SettingsSection>
@@ -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 (
<>
<SectionHeading
title="Workflow"
description="Choose whether Tolaria shows the Inbox workflow and the organized toggle."
description="Choose whether Tolaria shows the Inbox workflow, plus how it moves through items while you triage them."
/>
<SettingsSwitchRow
@@ -725,6 +740,14 @@ function OrganizationWorkflowSection({
onChange={onChange}
testId="settings-explicit-organization"
/>
<SettingsSwitchRow
label="Auto-advance to next Inbox item"
description="When enabled, marking an Inbox note as organized immediately opens the next visible Inbox note."
checked={autoAdvanceInboxAfterOrganize}
onChange={onChangeAutoAdvanceInboxAfterOrganize}
testId="settings-auto-advance-inbox-after-organize"
/>
</>
)
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<string, (args: any) => 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,

View File

@@ -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