diff --git a/src/App.tsx b/src/App.tsx index c3ba7d85..382a3667 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,12 +15,10 @@ import { PulseView } from './components/PulseView' import { StatusBar } from './components/StatusBar' import { SettingsPanel } from './components/SettingsPanel' import { CloneVaultModal } from './components/CloneVaultModal' -import { WelcomeScreen } from './components/WelcomeScreen' -import { AiAgentsOnboardingPrompt } from './components/AiAgentsOnboardingPrompt' -import { TelemetryConsentDialog } from './components/TelemetryConsentDialog' import { FeedbackDialog } from './components/FeedbackDialog' import { McpSetupDialog } from './components/McpSetupDialog' import { NoteRetargetingDialogs } from './components/note-retargeting/NoteRetargetingDialogs' +import { StartupScreen } from './components/StartupScreen' import { useTelemetry } from './hooks/useTelemetry' import { useMcpStatus } from './hooks/useMcpStatus' import { useAiAgentsOnboarding } from './hooks/useAiAgentsOnboarding' @@ -73,6 +71,12 @@ import { useAppSave } from './hooks/useAppSave' import { useNoteRetargetingUi } from './hooks/useNoteRetargetingUi' import { useVaultBridge } from './hooks/useVaultBridge' import { useSavedViewOrdering } from './hooks/useSavedViewOrdering' +import { + useNeighborhoodEntry, + useNeighborhoodEscape, + useNeighborhoodHistoryBack, + useSelectionSanitizer, +} from './hooks/useNeighborhoodSelection' import { createViewFilename } from './utils/viewFilename' import { nextViewOrder } from './utils/viewOrdering' import type { CommitDiffRequest } from './hooks/useDiffMode' @@ -115,14 +119,6 @@ import { extractDeletedContentFromDiff } from './components/note-list/noteListUt import { isActiveVaultUnavailableError } from './utils/vaultErrors' import { hasNoteIconValue } from './utils/noteIcon' import { filenameStemToTitle } from './utils/noteTitle' -import { - focusNoteListContainer, - isEditableElement, - isEditorEscapeTarget, - popNeighborhoodHistory, - pushNeighborhoodHistory, - shouldProcessNeighborhoodEscape, -} from './utils/neighborhoodHistory' import { OPEN_AI_CHAT_EVENT } from './utils/aiPromptBridge' import { INBOX_SELECTION, @@ -244,18 +240,15 @@ function App() { if (!options?.preserveNeighborhoodHistory && sel.kind !== 'entity') { neighborhoodHistoryRef.current = [] } + selectionRef.current = sel setSelection(sel) setNoteListFilter('open') }, []) - const handleEnterNeighborhood = useCallback((entry: VaultEntry) => { - const nextSelection: SidebarSelection = { kind: 'entity', entry } - neighborhoodHistoryRef.current = pushNeighborhoodHistory( - neighborhoodHistoryRef.current, - selectionRef.current, - nextSelection, - ) - handleSetSelection(nextSelection, { preserveNeighborhoodHistory: true }) - }, [handleSetSelection]) + const handleEnterNeighborhood = useNeighborhoodEntry({ + neighborhoodHistoryRef, + selectionRef, + setSelection: handleSetSelection, + }) const layout = useLayoutPanels(noteWindowParams ? { initialInspectorCollapsed: true } : undefined) const { setInspectorCollapsed } = layout const visibleNotesRef = useRef([]) @@ -419,31 +412,19 @@ function App() { const explicitOrganizationEnabled = isExplicitOrganizationEnabled(vaultConfig.inbox?.explicitOrganization) const effectiveSelection = sanitizeSelectionForOrganization(selection, vaultConfig.inbox?.explicitOrganization) - useEffect(() => { - selectionRef.current = effectiveSelection - }, [effectiveSelection]) + useSelectionSanitizer({ + effectiveSelection, + neighborhoodHistoryRef, + selection, + selectionRef, + setNoteListFilter, + setSelection, + }) - useEffect(() => { - if (effectiveSelection !== selection) { - if (effectiveSelection.kind !== 'entity') { - neighborhoodHistoryRef.current = [] - } - setSelection(effectiveSelection) - setNoteListFilter('open') - } - }, [effectiveSelection, selection]) - - const handleNeighborhoodHistoryBack = useCallback(() => { - const { previousSelection, nextHistory } = popNeighborhoodHistory(neighborhoodHistoryRef.current) - if (!previousSelection) return false - - neighborhoodHistoryRef.current = nextHistory - handleSetSelection(previousSelection, { preserveNeighborhoodHistory: true }) - requestAnimationFrame(() => { - focusNoteListContainer(document) - }) - return true - }, [handleSetSelection]) + const handleNeighborhoodHistoryBack = useNeighborhoodHistoryBack({ + neighborhoodHistoryRef, + setSelection: handleSetSelection, + }) const handleSaveExplicitOrganization = useCallback((enabled: boolean) => { updateConfig('inbox', { @@ -1409,30 +1390,11 @@ function App() { || showFeedback ) - useEffect(() => { - const handleWindowKeyDown = (event: KeyboardEvent) => { - if (!shouldProcessNeighborhoodEscape(event, selectionRef.current, shouldBlockNeighborhoodEscape)) return - - const activeElement = document.activeElement - if (isEditorEscapeTarget(activeElement)) { - event.preventDefault() - activeElement.blur() - requestAnimationFrame(() => { - focusNoteListContainer(document) - }) - return - } - - if (isEditableElement(activeElement)) return - - if (handleNeighborhoodHistoryBack()) { - event.preventDefault() - } - } - - window.addEventListener('keydown', handleWindowKeyDown) - return () => window.removeEventListener('keydown', handleWindowKeyDown) - }, [handleNeighborhoodHistoryBack, shouldBlockNeighborhoodEscape]) + useNeighborhoodEscape({ + onBack: handleNeighborhoodHistoryBack, + selectionRef, + shouldBlockNeighborhoodEscape, + }) const noteListColumnsLabel = useMemo(() => { if (effectiveSelection.kind === 'view') { @@ -1684,56 +1646,36 @@ function App() { const isStartupLoading = !noteWindowParams && onboarding.state.status === 'loading' - // Show telemetry consent dialog on first launch (skip for note windows). - // After the user answers, the next render can continue into onboarding. - if (!noteWindowParams && !isStartupLoading && settingsLoaded && settings.telemetry_consent === null) { + const shouldShowStartupScreen = !noteWindowParams && ( + (!isStartupLoading && settingsLoaded && settings.telemetry_consent === null) + || Boolean(runtimeMissingVaultPath) + || onboarding.state.status === 'welcome' + || onboarding.state.status === 'vault-missing' + || shouldResumeFreshStartOnboarding + || (onboarding.state.status === 'ready' && aiAgentsOnboarding.showPrompt && !showMcpSetupDialog) + ) + if (shouldShowStartupScreen) { return ( - { - const id = crypto.randomUUID() - saveSettings({ ...settings, telemetry_consent: true, crash_reporting_enabled: true, analytics_enabled: true, anonymous_id: id }) - }} - onDecline={() => { - saveSettings({ ...settings, telemetry_consent: false, crash_reporting_enabled: false, analytics_enabled: false, anonymous_id: null }) - }} + ) } - // Show welcome/onboarding screen when vault doesn't exist (skip for note windows — vault path is known) - if (!noteWindowParams && (runtimeMissingVaultPath || onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing' || shouldResumeFreshStartOnboarding)) { - const welcomeOnboarding = runtimeMissingVaultPath - ? { - ...onboarding, - state: { - status: 'vault-missing' as const, - vaultPath: runtimeMissingVaultPath, - defaultPath: vaultSwitcher.defaultPath || runtimeMissingVaultPath, - }, - } - : shouldResumeFreshStartOnboarding - ? { ...onboarding, state: { status: 'welcome' as const, defaultPath: vaultSwitcher.vaultPath } } - : onboarding - return - } - - if ( - !noteWindowParams - && onboarding.state.status === 'ready' - && aiAgentsOnboarding.showPrompt - && !showMcpSetupDialog - ) { - return ( - <> - - setToastMessage(null)} /> - - ) - } - const isVaultContentLoading = !noteWindowParams && (isStartupLoading || (onboarding.state.status === 'ready' && vault.isLoading)) return ( @@ -1905,42 +1847,4 @@ function App() { ) } -type OnboardingState = ReturnType - -/** Welcome screen view - extracted from main App component */ -function WelcomeView({ onboarding, isOffline }: { onboarding: OnboardingState; isOffline: boolean }) { - const state = onboarding.state as { status: 'welcome' | 'vault-missing'; defaultPath: string; vaultPath?: string } - return ( -
- -
- ) -} - -function AiAgentsOnboardingView({ - statuses, - onContinue, -}: { - statuses: ReturnType - onContinue: () => void -}) { - return ( -
- -
- ) -} - export default App diff --git a/src/components/StartupScreen.tsx b/src/components/StartupScreen.tsx new file mode 100644 index 00000000..e325539d --- /dev/null +++ b/src/components/StartupScreen.tsx @@ -0,0 +1,156 @@ +import { AiAgentsOnboardingPrompt } from './AiAgentsOnboardingPrompt' +import { TelemetryConsentDialog } from './TelemetryConsentDialog' +import { Toast } from './Toast' +import { WelcomeScreen } from './WelcomeScreen' +import type { useAiAgentsOnboarding } from '../hooks/useAiAgentsOnboarding' +import type { useAiAgentsStatus } from '../hooks/useAiAgentsStatus' +import type { useOnboarding } from '../hooks/useOnboarding' +import type { useVaultSwitcher } from '../hooks/useVaultSwitcher' +import type { Settings } from '../types' +import type { NoteWindowParams } from '../utils/windowMode' + +type OnboardingState = ReturnType +type VaultSwitcherState = ReturnType +type AiAgentsOnboardingState = ReturnType + +export interface StartupScreenParams { + aiAgentsOnboarding: AiAgentsOnboardingState + aiAgentsStatus: ReturnType + isOffline: boolean + isStartupLoading: boolean + noteWindowParams: NoteWindowParams | null + onboarding: OnboardingState + runtimeMissingVaultPath: string | null + saveSettings: (settings: Settings) => Promise + settings: Settings + settingsLoaded: boolean + shouldResumeFreshStartOnboarding: boolean + showMcpSetupDialog: boolean + setToastMessage: (message: string | null) => void + toastMessage: string | null + vaultSwitcher: VaultSwitcherState +} + +function shouldShowTelemetryConsent(params: StartupScreenParams): boolean { + return !params.noteWindowParams + && !params.isStartupLoading + && params.settingsLoaded + && params.settings.telemetry_consent === null +} + +function shouldShowWelcomeView(params: StartupScreenParams): boolean { + return !params.noteWindowParams + && ( + Boolean(params.runtimeMissingVaultPath) + || params.onboarding.state.status === 'welcome' + || params.onboarding.state.status === 'vault-missing' + || params.shouldResumeFreshStartOnboarding + ) +} + +function welcomeOnboardingState(params: StartupScreenParams): OnboardingState { + if (params.runtimeMissingVaultPath) { + return { + ...params.onboarding, + state: { + status: 'vault-missing' as const, + vaultPath: params.runtimeMissingVaultPath, + defaultPath: params.vaultSwitcher.defaultPath || params.runtimeMissingVaultPath, + }, + } + } + if (params.shouldResumeFreshStartOnboarding) { + return { ...params.onboarding, state: { status: 'welcome' as const, defaultPath: params.vaultSwitcher.vaultPath } } + } + return params.onboarding +} + +function shouldShowAiAgentsOnboarding(params: StartupScreenParams): boolean { + return !params.noteWindowParams + && params.onboarding.state.status === 'ready' + && params.aiAgentsOnboarding.showPrompt + && !params.showMcpSetupDialog +} + +function WelcomeView({ onboarding, isOffline }: { onboarding: OnboardingState; isOffline: boolean }) { + const state = onboarding.state as { status: 'welcome' | 'vault-missing'; defaultPath: string; vaultPath?: string } + return ( +
+ +
+ ) +} + +function AiAgentsOnboardingView({ + statuses, + onContinue, +}: { + statuses: ReturnType + onContinue: () => void +}) { + return ( +
+ +
+ ) +} + +export function StartupScreen(params: StartupScreenParams) { + if (shouldShowTelemetryConsent(params)) { + return ( + { + const id = crypto.randomUUID() + params.saveSettings({ + ...params.settings, + telemetry_consent: true, + crash_reporting_enabled: true, + analytics_enabled: true, + anonymous_id: id, + }) + }} + onDecline={() => { + params.saveSettings({ + ...params.settings, + telemetry_consent: false, + crash_reporting_enabled: false, + analytics_enabled: false, + anonymous_id: null, + }) + }} + /> + ) + } + + if (shouldShowWelcomeView(params)) { + return ( + + ) + } + + if (!shouldShowAiAgentsOnboarding(params)) return null + return ( + <> + + params.setToastMessage(null)} /> + + ) +} diff --git a/src/hooks/useNeighborhoodSelection.test.ts b/src/hooks/useNeighborhoodSelection.test.ts new file mode 100644 index 00000000..c1c0b452 --- /dev/null +++ b/src/hooks/useNeighborhoodSelection.test.ts @@ -0,0 +1,121 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { MutableRefObject } from 'react' +import type { SidebarSelection, VaultEntry } from '../types' +import { + useNeighborhoodEntry, + useNeighborhoodEscape, +} from './useNeighborhoodSelection' + +function buildEntry(path: string, title: string): VaultEntry { + return { + path, + filename: `${title.toLowerCase()}.md`, + title, + isA: 'Note', + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + archived: false, + owner: null, + cadence: null, + modifiedAt: 1, + createdAt: null, + fileSize: 1, + snippet: '', + wordCount: 1, + relationships: {}, + icon: null, + color: null, + order: null, + sidebarLabel: null, + template: null, + sort: null, + view: null, + visible: true, + organized: false, + favorite: false, + favoriteIndex: null, + listPropertiesDisplay: [], + outgoingLinks: [], + properties: {}, + hasH1: true, + fileKind: 'markdown', + } +} + +function ref(current: T): MutableRefObject { + return { current } +} + +const inboxSelection: SidebarSelection = { kind: 'filter', filter: 'inbox' } +const alphaSelection: SidebarSelection = { kind: 'entity', entry: buildEntry('/vault/alpha.md', 'Alpha') } +const betaSelection: SidebarSelection = { kind: 'entity', entry: buildEntry('/vault/beta.md', 'Beta') } + +describe('useNeighborhoodEntry', () => { + beforeEach(() => { + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0) + return 0 + }) + document.body.innerHTML = '
' + }) + + afterEach(() => { + vi.unstubAllGlobals() + document.body.innerHTML = '' + }) + + it('toggles the repeated active neighborhood action back to all notes', () => { + const selectionRef = ref(alphaSelection) + const historyRef = ref([inboxSelection]) + const setSelection = vi.fn((selection: SidebarSelection) => { + selectionRef.current = selection + }) + const { result } = renderHook(() => useNeighborhoodEntry({ + neighborhoodHistoryRef: historyRef, + selectionRef, + setSelection, + })) + + act(() => result.current(alphaSelection.entry)) + + expect(setSelection).toHaveBeenCalledWith({ kind: 'filter', filter: 'all' }) + expect(historyRef.current).toEqual([inboxSelection]) + expect(document.activeElement).toBe(document.querySelector('[data-testid="note-list-container"]')) + }) + + it('switches between neighborhoods without collapsing to all notes', () => { + const selectionRef = ref(alphaSelection) + const historyRef = ref([inboxSelection]) + const setSelection = vi.fn() + const { result } = renderHook(() => useNeighborhoodEntry({ + neighborhoodHistoryRef: historyRef, + selectionRef, + setSelection, + })) + + act(() => result.current(betaSelection.entry)) + + expect(setSelection).toHaveBeenCalledWith(betaSelection, { preserveNeighborhoodHistory: true }) + expect(historyRef.current).toEqual([inboxSelection, alphaSelection]) + }) +}) + +describe('useNeighborhoodEscape', () => { + it('routes Escape to neighborhood history when focus is already outside editable controls', () => { + const onBack = vi.fn(() => true) + renderHook(() => useNeighborhoodEscape({ + onBack, + selectionRef: ref(alphaSelection), + shouldBlockNeighborhoodEscape: false, + })) + + const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) + window.dispatchEvent(event) + + expect(onBack).toHaveBeenCalledOnce() + expect(event.defaultPrevented).toBe(true) + }) +}) diff --git a/src/hooks/useNeighborhoodSelection.ts b/src/hooks/useNeighborhoodSelection.ts new file mode 100644 index 00000000..edfad260 --- /dev/null +++ b/src/hooks/useNeighborhoodSelection.ts @@ -0,0 +1,145 @@ +import { useCallback, useEffect } from 'react' +import type { MutableRefObject } from 'react' +import type { SidebarSelection, VaultEntry } from '../types' +import type { NoteListFilter } from '../utils/noteListHelpers' +import { trackEvent } from '../lib/telemetry' +import { + focusNoteListContainer, + isEditableElement, + isEditorEscapeTarget, + popNeighborhoodHistory, + pushNeighborhoodHistory, + resolveNeighborhoodSelection, + shouldProcessNeighborhoodEscape, +} from '../utils/neighborhoodHistory' + +interface SetSelectionOptions { + preserveNeighborhoodHistory?: boolean +} + +type SetSelection = (selection: SidebarSelection, options?: SetSelectionOptions) => void + +interface NeighborhoodRefs { + neighborhoodHistoryRef: MutableRefObject + selectionRef: MutableRefObject +} + +interface UseNeighborhoodEntryOptions extends NeighborhoodRefs { + setSelection: SetSelection +} + +interface UseSelectionSanitizerOptions extends NeighborhoodRefs { + effectiveSelection: SidebarSelection + selection: SidebarSelection + setNoteListFilter: (filter: NoteListFilter) => void + setSelection: (selection: SidebarSelection) => void +} + +interface UseNeighborhoodHistoryBackOptions { + neighborhoodHistoryRef: MutableRefObject + setSelection: SetSelection +} + +interface UseNeighborhoodEscapeOptions { + onBack: () => boolean + selectionRef: MutableRefObject + shouldBlockNeighborhoodEscape: boolean +} + +function focusNoteListOnNextFrame(): void { + requestAnimationFrame(() => { + focusNoteListContainer(document) + }) +} + +export function useNeighborhoodEntry({ + neighborhoodHistoryRef, + selectionRef, + setSelection, +}: UseNeighborhoodEntryOptions) { + return useCallback((entry: VaultEntry) => { + const currentSelection = selectionRef.current + const nextSelection = resolveNeighborhoodSelection(currentSelection, entry) + trackEvent('neighborhood_mode_toggled', { action: nextSelection.action }) + + if (nextSelection.action === 'exit') { + setSelection(nextSelection.selection) + focusNoteListOnNextFrame() + return + } + + neighborhoodHistoryRef.current = pushNeighborhoodHistory( + neighborhoodHistoryRef.current, + currentSelection, + nextSelection.selection, + ) + setSelection(nextSelection.selection, { preserveNeighborhoodHistory: true }) + }, [neighborhoodHistoryRef, selectionRef, setSelection]) +} + +export function useSelectionSanitizer({ + effectiveSelection, + neighborhoodHistoryRef, + selection, + selectionRef, + setNoteListFilter, + setSelection, +}: UseSelectionSanitizerOptions): void { + useEffect(() => { + selectionRef.current = effectiveSelection + }, [effectiveSelection, selectionRef]) + + useEffect(() => { + if (effectiveSelection === selection) return + + if (effectiveSelection.kind !== 'entity') { + neighborhoodHistoryRef.current = [] + } + setSelection(effectiveSelection) + setNoteListFilter('open') + }, [effectiveSelection, neighborhoodHistoryRef, selection, setNoteListFilter, setSelection]) +} + +export function useNeighborhoodHistoryBack({ + neighborhoodHistoryRef, + setSelection, +}: UseNeighborhoodHistoryBackOptions) { + return useCallback(() => { + const { previousSelection, nextHistory } = popNeighborhoodHistory(neighborhoodHistoryRef.current) + if (!previousSelection) return false + + neighborhoodHistoryRef.current = nextHistory + setSelection(previousSelection, { preserveNeighborhoodHistory: true }) + focusNoteListOnNextFrame() + return true + }, [neighborhoodHistoryRef, setSelection]) +} + +export function useNeighborhoodEscape({ + onBack, + selectionRef, + shouldBlockNeighborhoodEscape, +}: UseNeighborhoodEscapeOptions): void { + useEffect(() => { + const handleWindowKeyDown = (event: KeyboardEvent) => { + if (!shouldProcessNeighborhoodEscape(event, selectionRef.current, shouldBlockNeighborhoodEscape)) return + + const activeElement = document.activeElement + if (isEditorEscapeTarget(activeElement)) { + event.preventDefault() + activeElement.blur() + focusNoteListOnNextFrame() + return + } + + if (isEditableElement(activeElement)) return + + if (onBack()) { + event.preventDefault() + } + } + + window.addEventListener('keydown', handleWindowKeyDown) + return () => window.removeEventListener('keydown', handleWindowKeyDown) + }, [onBack, selectionRef, shouldBlockNeighborhoodEscape]) +} diff --git a/src/utils/neighborhoodHistory.test.ts b/src/utils/neighborhoodHistory.test.ts index 09794541..f127a2ba 100644 --- a/src/utils/neighborhoodHistory.test.ts +++ b/src/utils/neighborhoodHistory.test.ts @@ -6,6 +6,7 @@ import { isEditableElement, popNeighborhoodHistory, pushNeighborhoodHistory, + resolveNeighborhoodSelection, selectionsEqual, shouldProcessNeighborhoodEscape, } from './neighborhoodHistory' @@ -62,6 +63,21 @@ describe('neighborhoodHistory', () => { expect(pushNeighborhoodHistory([inboxSelection], alphaSelection, alphaSelection)).toEqual([inboxSelection]) }) + it('resolves Neighborhood entry as enter, switch, or exit actions', () => { + expect(resolveNeighborhoodSelection(inboxSelection, alphaSelection.entry)).toEqual({ + action: 'enter', + selection: alphaSelection, + }) + expect(resolveNeighborhoodSelection(alphaSelection, betaSelection.entry)).toEqual({ + action: 'switch', + selection: betaSelection, + }) + expect(resolveNeighborhoodSelection(alphaSelection, buildEntry('/vault/alpha.md', 'Alpha copy'))).toEqual({ + action: 'exit', + selection: { kind: 'filter', filter: 'all' }, + }) + }) + it('pops one level of note-list history at a time', () => { expect(popNeighborhoodHistory([inboxSelection, alphaSelection])).toEqual({ previousSelection: alphaSelection, diff --git a/src/utils/neighborhoodHistory.ts b/src/utils/neighborhoodHistory.ts index f4e1e0a4..e3992357 100644 --- a/src/utils/neighborhoodHistory.ts +++ b/src/utils/neighborhoodHistory.ts @@ -1,8 +1,15 @@ -import type { SidebarSelection } from '../types' +import type { SidebarSelection, VaultEntry } from '../types' const NOTE_LIST_CONTAINER_SELECTOR = '[data-testid="note-list-container"]' const EDITOR_SURFACE_SELECTOR = '.editor__blocknote-container, .cm-editor' +export type NeighborhoodSelectionAction = 'enter' | 'switch' | 'exit' + +export interface NeighborhoodSelectionUpdate { + action: NeighborhoodSelectionAction + selection: SidebarSelection +} + function isSameFilterSelection(a: Extract, b: Extract) { return a.filter === b.filter } @@ -49,6 +56,21 @@ export function pushNeighborhoodHistory( return [...history, currentSelection] } +export function resolveNeighborhoodSelection( + currentSelection: SidebarSelection, + entry: VaultEntry, +): NeighborhoodSelectionUpdate { + const nextSelection: SidebarSelection = { kind: 'entity', entry } + if (selectionsEqual(currentSelection, nextSelection)) { + return { action: 'exit', selection: { kind: 'filter', filter: 'all' } } + } + + return { + action: currentSelection.kind === 'entity' ? 'switch' : 'enter', + selection: nextSelection, + } +} + export function popNeighborhoodHistory(history: SidebarSelection[]) { if (history.length === 0) return { previousSelection: null, nextHistory: history }