diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 3e824992..d78bea47 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -839,6 +839,9 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| `App.tsx` | `selection`, panel widths, dialog visibility, toast, view mode | UI state |
| `useVaultLoader` | `entries`, `allContent`, `modifiedFiles` | Vault data |
| `useNoteActions` | `tabs`, `activeTabPath` | Composes `useNoteCreation` + `useNoteRename` + `frontmatterOps` |
+| `useNoteWindowLifecycle` | note-window open/title side effects | Opens `tauri://` note windows without full vault scans and keeps the native title current |
+| `useStartupScreenState` | startup visibility booleans | Keeps onboarding, telemetry-consent, missing-vault, and initial indexing decisions out of `App.tsx` |
+| `useVaultRenameDetection` | detected rename banner state | Detects external Git renames on focus and owns the wikilink update callback |
| `useNoteCreation` | — | Note/type creation with optimistic persistence |
| `useNoteRename` | — | Note renaming and folder moves with wikilink update |
| `useNoteRetargeting` | — | Shared note retargeting logic for drag/drop and command-palette actions |
diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md
index 448de898..9432d84f 100644
--- a/docs/GETTING-STARTED.md
+++ b/docs/GETTING-STARTED.md
@@ -77,7 +77,7 @@ Linux AppImage builds still use the user's system `git`. Before Tolaria spawns t
tolaria/
├── src/ # React frontend
│ ├── main.tsx # Entry point (renders )
-│ ├── App.tsx # Root component — orchestrates layout + state
+│ ├── App.tsx # Root component — wires layout + state hooks
│ ├── App.css # App shell layout styles
│ ├── types.ts # Shared TS types (VaultEntry, Settings, etc.)
│ ├── mock-tauri.ts # Mock Tauri layer for browser testing
@@ -124,7 +124,7 @@ tolaria/
│ │ └── ui/ # shadcn/ui primitives
│ │ ├── button.tsx, dialog.tsx, input.tsx, ...
│ │
-│ ├── hooks/ # Custom React hooks (~86 files)
+│ ├── hooks/ # Custom React hooks (~90 files)
│ │ ├── useVaultLoader.ts # Loads vault entries + content
│ │ ├── useVaultSwitcher.ts # Multi-vault management
│ │ ├── useVaultConfig.ts # Per-vault UI settings
@@ -258,7 +258,7 @@ tolaria/
| File | Why it matters |
|------|---------------|
-| `src/App.tsx` | Root component. Shows the 4-panel layout, state flow, and how all features connect. |
+| `src/App.tsx` | Root component. Shows the 4-panel layout, state flow, and how orchestration hooks connect. |
| `src/types.ts` | All shared TypeScript types. Read this first to understand the data model. |
| `src-tauri/src/commands/` | Tauri command handlers (split into modules). This is the frontend-backend API surface. |
| `src-tauri/src/lib.rs` | Tauri setup, command registration, startup tasks, WebSocket bridge lifecycle. |
@@ -271,6 +271,9 @@ tolaria/
| `src/hooks/useNoteActions.ts` | Orchestrates note operations: composes `useNoteCreation`, `useNoteRename`, frontmatter CRUD, and wikilink navigation. |
| `src/hooks/useVaultSwitcher.ts` | Multi-vault management, vault switching, and persisting cloned vaults in the switcher list. |
| `src/hooks/useGettingStartedClone.ts` | Shared "Clone Getting Started Vault" action for the status bar and command palette. |
+| `src/hooks/useNoteWindowLifecycle.ts` | Note-window URL opening, asset-scope sync, and window-title updates. |
+| `src/hooks/useVaultRenameDetection.ts` | Focus-triggered Git rename detection and wikilink update action wiring. |
+| `src/hooks/useStartupScreenState.ts` | Startup-screen and vault-content loading visibility decisions. |
| `src/components/AddRemoteModal.tsx` | Modal UI for connecting a local-only vault to a compatible remote. |
| `src/mock-tauri.ts` | Mock data for browser testing. Shows the shape of all Tauri responses. |
diff --git a/src/App.tsx b/src/App.tsx
index 5bfd2b65..0cfb12f6 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -89,9 +89,9 @@ import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/
import { openNoteInNewWindow } from './utils/openNoteWindow'
import { isWindows } from './utils/platform'
import { refreshPulledVaultState } from './utils/pulledVaultRefresh'
-import { isNoteWindow, getNoteWindowParams, getNoteWindowPathCandidates, type NoteWindowParams } from './utils/windowMode'
+import { isNoteWindow, getNoteWindowParams } from './utils/windowMode'
import { GitSetupDialog } from './components/GitRequiredModal'
-import { RenameDetectedBanner, type DetectedRename } from './components/RenameDetectedBanner'
+import { RenameDetectedBanner } from './components/RenameDetectedBanner'
import { openNoteListPropertiesPicker } from './components/note-list/noteListPropertiesEvents'
import type { NoteListMultiSelectionCommands } from './components/note-list/multiSelectionCommands'
import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents'
@@ -125,6 +125,10 @@ import { useVisibleWorkspaceEntries, useWorkspaceGraphState } from './hooks/useW
import { useGitSetupState } from './hooks/useGitSetupState'
import { useAppPreferences } from './hooks/useAppPreferences'
import { useInboxOrganizeAdvance } from './hooks/useInboxOrganizeAdvance'
+import { syncVaultAssetScope, useNoteWindowLifecycle } from './hooks/useNoteWindowLifecycle'
+import { useVaultRenameDetection } from './hooks/useVaultRenameDetection'
+import { useVaultOpenedTelemetry } from './hooks/useVaultOpenedTelemetry'
+import { useStartupScreenState } from './hooks/useStartupScreenState'
import './App.css'
const ACTIVE_EDITOR_SURFACE_SELECTOR = '.editor__blocknote-container, .raw-editor-codemirror'
@@ -156,37 +160,10 @@ function shouldPreferOnboardingVaultPath(
&& !vaults.some((vault) => vault.path === onboardingState.vaultPath)
}
-async function resolveNoteWindowEntry(noteWindowParams: NoteWindowParams): Promise {
- for (const path of getNoteWindowPathCandidates(noteWindowParams)) {
- try {
- const request = { path, vaultPath: noteWindowParams.vaultPath }
- const entry = isTauri()
- ? await invoke('reload_vault_entry', request)
- : await mockInvoke('reload_vault_entry', request)
- if (entry) return entry
- } catch {
- // Try the next normalized candidate before reporting the note as unavailable.
- }
- }
-}
-
-async function loadNoteWindowContent(path: string, vaultPath: string): Promise {
- const request = { path, vaultPath }
- if (!isTauri()) return mockInvoke('get_note_content', request)
-
- await invoke('sync_vault_asset_scope_for_window', { vaultPath })
- return invoke('get_note_content', request)
-}
-
function appTauriCall(command: string, args: Record): Promise {
return isTauri() ? invoke(command, args) : mockInvoke(command, args)
}
-async function syncVaultAssetScope(vaultPath: string): Promise {
- if (!isTauri() || !vaultPath.trim()) return
- await invoke('sync_vault_asset_scope_for_window', { vaultPath })
-}
-
function canCustomizeColumnsForSelection(
selection: SidebarSelection,
explicitOrganizationEnabled: boolean,
@@ -465,13 +442,11 @@ function App() {
settingsLoaded,
})
- const vaultOpenedRef = useRef('')
- useEffect(() => {
- if (vault.entries.length > 0 && gitRepoState !== 'checking' && resolvedPath !== vaultOpenedRef.current) {
- vaultOpenedRef.current = resolvedPath
- trackEvent('vault_opened', { has_git: gitRepoState === 'ready' ? 1 : 0, note_count: vault.entries.length })
- }
- }, [vault.entries.length, gitRepoState, resolvedPath])
+ useVaultOpenedTelemetry({
+ entryCount: vault.entries.length,
+ gitRepoState,
+ resolvedPath,
+ })
const {
mcpStatus,
connectMcp,
@@ -557,32 +532,15 @@ function App() {
void loadMcpConfigSnippet().catch(() => undefined)
}, [loadMcpConfigSnippet])
- // Detect external file renames on window focus
- const [detectedRenames, setDetectedRenames] = useState([])
- useEffect(() => {
- if (!isTauri() || !resolvedPath) return
- const handleFocus = () => {
- invoke('detect_renames', { args: { vaultPath: resolvedPath } })
- .then(renames => { if (renames.length > 0) setDetectedRenames(renames) })
- .catch((err) => console.warn('[vault] Git rename detection failed:', err))
- }
- window.addEventListener('focus', handleFocus)
- return () => window.removeEventListener('focus', handleFocus)
- }, [resolvedPath])
-
- const handleUpdateWikilinks = useCallback(async () => {
- if (!isTauri()) return
- try {
- const count = await invoke('update_wikilinks_for_renames', { args: { vaultPath: resolvedPath, renames: detectedRenames } })
- setDetectedRenames([])
- vault.reloadVault()
- setToastMessage(`Updated wikilinks in ${count} file${count !== 1 ? 's' : ''}`)
- } catch (err) {
- setToastMessage(`Failed to update wikilinks: ${err}`)
- }
- }, [resolvedPath, detectedRenames, vault, setToastMessage])
-
- const handleDismissRenames = useCallback(() => setDetectedRenames([]), [])
+ const {
+ detectedRenames,
+ handleUpdateWikilinks,
+ handleDismissRenames,
+ } = useVaultRenameDetection({
+ reloadVault: vault.reloadVault,
+ setToastMessage,
+ vaultPath: resolvedPath,
+ })
const conflictResolver = useConflictResolver({
vaultPath: resolvedPath,
@@ -646,10 +604,14 @@ function App() {
closeAllTabs,
openTabWithContent,
} = notes
- const noteWindowActionsRef = useRef({ handleSelectNote, openTabWithContent })
- useEffect(() => {
- noteWindowActionsRef.current = { handleSelectNote, openTabWithContent }
- }, [handleSelectNote, openTabWithContent])
+ useNoteWindowLifecycle({
+ activeTabPath: notes.activeTabPath,
+ handleSelectNote,
+ noteWindowParams,
+ openTabWithContent,
+ setToastMessage,
+ tabs: notes.tabs,
+ })
const handleVaultUpdate = useCallback(async (
updatedFiles: string[],
options: { preserveFocusedEditor?: boolean } = {},
@@ -722,46 +684,6 @@ function App() {
const pendingDiffRequestIdRef = useRef(0)
const [pendingDiffRequest, setPendingDiffRequest] = useState(null)
- // Note window: auto-open the note from URL params without scanning the whole vault.
- const noteWindowOpenedRef = useRef(false)
- const noteWindowMissingPathRef = useRef(null)
- useEffect(() => {
- if (!noteWindowParams || noteWindowOpenedRef.current) return
-
- void resolveNoteWindowEntry(noteWindowParams).then(async (entry) => {
- if (noteWindowOpenedRef.current) return
- if (entry) {
- try {
- const content = await loadNoteWindowContent(entry.path, noteWindowParams.vaultPath)
- if (noteWindowOpenedRef.current) return
- noteWindowOpenedRef.current = true
- noteWindowMissingPathRef.current = null
- noteWindowActionsRef.current.openTabWithContent(entry, content)
- } catch {
- if (noteWindowOpenedRef.current) return
- noteWindowOpenedRef.current = true
- noteWindowMissingPathRef.current = null
- void noteWindowActionsRef.current.handleSelectNote(entry)
- }
- return
- }
- if (noteWindowMissingPathRef.current === noteWindowParams.notePath) return
- noteWindowMissingPathRef.current = noteWindowParams.notePath
- setToastMessage(`Could not open "${noteWindowParams.noteTitle}" in this window`)
- })
- }, [noteWindowParams, setToastMessage])
-
- // Note window: update window title when active note changes
- useEffect(() => {
- if (!noteWindowParams) return
- const activeEntry = notes.tabs.find(t => t.entry.path === notes.activeTabPath)?.entry
- const title = activeEntry?.title ?? noteWindowParams.noteTitle
- if (!isTauri()) { document.title = title; return }
- import('@tauri-apps/api/window').then(({ getCurrentWindow }) => {
- getCurrentWindow().setTitle(title)
- }).catch((err) => console.warn('[window] Failed to update note window title:', err))
- }, [noteWindowParams, notes.tabs, notes.activeTabPath])
-
// Keep note entry in sync with vault entries so banners (trash/archive)
// and read-only state react immediately without reopening the note.
useEffect(() => {
@@ -1795,25 +1717,23 @@ function App() {
return { type: null, query: '' }
}, [effectiveSelection])
- const shouldResumeFreshStartOnboarding = useMemo(() => {
- if (onboarding.state.status !== 'ready' || !vaultSwitcher.loaded) return false
- const remembersOnlyImplicitDefaultVault = selectedVaultPath === null
-
- return remembersOnlyImplicitDefaultVault
- && vaultSwitcher.allVaults.length === 1
- && vaultSwitcher.allVaults[0]?.path === vaultSwitcher.vaultPath
- && onboarding.state.vaultPath === vaultSwitcher.vaultPath
- }, [onboarding.state, selectedVaultPath, vaultSwitcher.allVaults, vaultSwitcher.loaded, vaultSwitcher.vaultPath])
-
- const isStartupLoading = !noteWindowParams && onboarding.state.status === 'loading'
- 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)
- )
+ const {
+ isStartupLoading,
+ isVaultContentLoading,
+ shouldResumeFreshStartOnboarding,
+ shouldShowStartupScreen,
+ } = useStartupScreenState({
+ aiAgentsPromptVisible: aiAgentsOnboarding.showPrompt,
+ isNoteWindow: Boolean(noteWindowParams),
+ onboardingState: onboarding.state,
+ runtimeMissingVaultPath,
+ selectedVaultPath,
+ settingsLoaded,
+ showMcpSetupDialog,
+ telemetryConsent: settings.telemetry_consent,
+ vaultIsLoading: vault.isLoading,
+ vaultSwitcher,
+ })
if (shouldShowStartupScreen) {
return (
unknown
+ openTabWithContent: (entry: VaultEntry, content: string) => unknown
+}
+
+interface UseNoteWindowLifecycleArgs extends NoteWindowActions {
+ activeTabPath: string | null
+ noteWindowParams: NoteWindowParams | null
+ setToastMessage: (message: string) => void
+ tabs: Array<{ entry: VaultEntry }>
+}
+
+interface OpenNoteWindowArgs {
+ actionsRef: RefObject
+ missingPathRef: RefObject
+ noteWindowParams: NoteWindowParams
+ openedRef: RefObject
+ setToastMessage: (message: string) => void
+}
+
+interface OpenResolvedNoteWindowEntryArgs extends Omit {
+ entry: VaultEntry
+}
+
+async function resolveNoteWindowEntry(noteWindowParams: NoteWindowParams): Promise {
+ for (const path of getNoteWindowPathCandidates(noteWindowParams)) {
+ try {
+ const request = { path, vaultPath: noteWindowParams.vaultPath }
+ const entry = isTauri()
+ ? await invoke('reload_vault_entry', request)
+ : await mockInvoke('reload_vault_entry', request)
+ if (entry) return entry
+ } catch {
+ // Try the next normalized candidate before reporting the note as unavailable.
+ }
+ }
+}
+
+async function loadNoteWindowContent(path: string, vaultPath: string): Promise {
+ const request = { path, vaultPath }
+ if (!isTauri()) return mockInvoke('get_note_content', request)
+
+ await syncVaultAssetScope(vaultPath)
+ return invoke('get_note_content', request)
+}
+
+export async function syncVaultAssetScope(vaultPath: string): Promise {
+ if (!isTauri() || !vaultPath.trim()) return
+ await invoke('sync_vault_asset_scope_for_window', { vaultPath })
+}
+
+async function openNoteWindowEntry({
+ actionsRef,
+ entry,
+ missingPathRef,
+ noteWindowParams,
+ openedRef,
+}: OpenResolvedNoteWindowEntryArgs): Promise {
+ try {
+ const content = await loadNoteWindowContent(entry.path, noteWindowParams.vaultPath)
+ if (openedRef.current) return
+ openedRef.current = true
+ missingPathRef.current = null
+ actionsRef.current.openTabWithContent(entry, content)
+ } catch {
+ if (openedRef.current) return
+ openedRef.current = true
+ missingPathRef.current = null
+ void actionsRef.current.handleSelectNote(entry)
+ }
+}
+
+function reportMissingNoteWindowPath({
+ missingPathRef,
+ noteWindowParams,
+ setToastMessage,
+}: Pick): void {
+ if (missingPathRef.current === noteWindowParams.notePath) return
+ missingPathRef.current = noteWindowParams.notePath
+ setToastMessage(`Could not open "${noteWindowParams.noteTitle}" in this window`)
+}
+
+async function resolveAndOpenNoteWindow({
+ actionsRef,
+ missingPathRef,
+ noteWindowParams,
+ openedRef,
+ setToastMessage,
+}: OpenNoteWindowArgs): Promise {
+ const entry = await resolveNoteWindowEntry(noteWindowParams)
+ if (openedRef.current) return
+ if (!entry) {
+ reportMissingNoteWindowPath({ missingPathRef, noteWindowParams, setToastMessage })
+ return
+ }
+
+ await openNoteWindowEntry({
+ actionsRef,
+ entry,
+ missingPathRef,
+ noteWindowParams,
+ openedRef,
+ })
+}
+
+function useNoteWindowActionsRef({
+ handleSelectNote,
+ openTabWithContent,
+}: NoteWindowActions): RefObject {
+ const actionsRef = useRef({ handleSelectNote, openTabWithContent })
+
+ useEffect(() => {
+ actionsRef.current = { handleSelectNote, openTabWithContent }
+ }, [handleSelectNote, openTabWithContent])
+
+ return actionsRef
+}
+
+function useOpenNoteWindowOnMount(
+ noteWindowParams: NoteWindowParams | null,
+ actionsRef: RefObject,
+ setToastMessage: (message: string) => void,
+): void {
+ const openedRef = useRef(false)
+ const missingPathRef = useRef(null)
+
+ useEffect(() => {
+ if (!noteWindowParams || openedRef.current) return
+
+ void resolveAndOpenNoteWindow({
+ actionsRef,
+ missingPathRef,
+ noteWindowParams,
+ openedRef,
+ setToastMessage,
+ })
+ }, [actionsRef, noteWindowParams, setToastMessage])
+}
+
+function useNoteWindowTitle(
+ noteWindowParams: NoteWindowParams | null,
+ tabs: Array<{ entry: VaultEntry }>,
+ activeTabPath: string | null,
+): void {
+ useEffect(() => {
+ if (!noteWindowParams) return
+
+ const activeEntry = tabs.find((tab) => tab.entry.path === activeTabPath)?.entry
+ const title = activeEntry?.title ?? noteWindowParams.noteTitle
+ if (!isTauri()) {
+ document.title = title
+ return
+ }
+
+ import('@tauri-apps/api/window')
+ .then(({ getCurrentWindow }) => {
+ getCurrentWindow().setTitle(title)
+ })
+ .catch((err) => console.warn('[window] Failed to update note window title:', err))
+ }, [activeTabPath, noteWindowParams, tabs])
+}
+
+export function useNoteWindowLifecycle({
+ activeTabPath,
+ handleSelectNote,
+ noteWindowParams,
+ openTabWithContent,
+ setToastMessage,
+ tabs,
+}: UseNoteWindowLifecycleArgs): void {
+ const actionsRef = useNoteWindowActionsRef({ handleSelectNote, openTabWithContent })
+
+ useOpenNoteWindowOnMount(noteWindowParams, actionsRef, setToastMessage)
+ useNoteWindowTitle(noteWindowParams, tabs, activeTabPath)
+}
diff --git a/src/hooks/useStartupScreenState.ts b/src/hooks/useStartupScreenState.ts
new file mode 100644
index 00000000..2fe7b58c
--- /dev/null
+++ b/src/hooks/useStartupScreenState.ts
@@ -0,0 +1,156 @@
+import { useMemo } from 'react'
+
+interface StartupOnboardingState {
+ status: string
+ vaultPath?: string
+}
+
+interface StartupVaultSwitcherState {
+ allVaults: Array<{ path: string }>
+ loaded: boolean
+ vaultPath: string
+}
+
+interface UseStartupScreenStateArgs {
+ aiAgentsPromptVisible: boolean
+ isNoteWindow: boolean
+ onboardingState: StartupOnboardingState
+ runtimeMissingVaultPath: string | null
+ selectedVaultPath: string | null
+ settingsLoaded: boolean
+ showMcpSetupDialog: boolean
+ telemetryConsent: boolean | null
+ vaultIsLoading: boolean
+ vaultSwitcher: StartupVaultSwitcherState
+}
+
+interface StartupScreenState {
+ isStartupLoading: boolean
+ isVaultContentLoading: boolean
+ shouldResumeFreshStartOnboarding: boolean
+ shouldShowStartupScreen: boolean
+}
+
+interface ShouldShowStartupScreenArgs {
+ aiAgentsPromptVisible: boolean
+ isNoteWindow: boolean
+ isStartupLoading: boolean
+ onboardingState: StartupOnboardingState
+ runtimeMissingVaultPath: string | null
+ settingsLoaded: boolean
+ shouldResumeFreshStartOnboarding: boolean
+ showMcpSetupDialog: boolean
+ telemetryConsent: boolean | null
+}
+
+function shouldResumeFreshStart(
+ onboardingState: StartupOnboardingState,
+ selectedVaultPath: string | null,
+ vaultSwitcher: StartupVaultSwitcherState,
+): boolean {
+ if (onboardingState.status !== 'ready' || !vaultSwitcher.loaded) return false
+
+ const remembersOnlyImplicitDefaultVault = selectedVaultPath === null
+ const hasOneRegisteredVault = vaultSwitcher.allVaults.length === 1
+ const registeredVaultPath = vaultSwitcher.allVaults[0]?.path
+ const switcherOwnsOnboardingVault = onboardingState.vaultPath === vaultSwitcher.vaultPath
+
+ return remembersOnlyImplicitDefaultVault
+ && hasOneRegisteredVault
+ && registeredVaultPath === vaultSwitcher.vaultPath
+ && switcherOwnsOnboardingVault
+}
+
+function needsTelemetryConsent(
+ isStartupLoading: boolean,
+ settingsLoaded: boolean,
+ telemetryConsent: boolean | null,
+): boolean {
+ return !isStartupLoading && settingsLoaded && telemetryConsent === null
+}
+
+function needsAiAgentsOnboarding(
+ onboardingState: StartupOnboardingState,
+ aiAgentsPromptVisible: boolean,
+ showMcpSetupDialog: boolean,
+): boolean {
+ return onboardingState.status === 'ready' && aiAgentsPromptVisible && !showMcpSetupDialog
+}
+
+function shouldShowStartupScreenForState({
+ aiAgentsPromptVisible,
+ isNoteWindow,
+ isStartupLoading,
+ onboardingState,
+ runtimeMissingVaultPath,
+ settingsLoaded,
+ shouldResumeFreshStartOnboarding,
+ showMcpSetupDialog,
+ telemetryConsent,
+}: ShouldShowStartupScreenArgs): boolean {
+ if (isNoteWindow) return false
+
+ const startupReasons = [
+ needsTelemetryConsent(isStartupLoading, settingsLoaded, telemetryConsent),
+ Boolean(runtimeMissingVaultPath),
+ onboardingState.status === 'welcome',
+ onboardingState.status === 'vault-missing',
+ shouldResumeFreshStartOnboarding,
+ needsAiAgentsOnboarding(onboardingState, aiAgentsPromptVisible, showMcpSetupDialog),
+ ]
+ return startupReasons.some(Boolean)
+}
+
+function isVaultContentLoading(
+ isNoteWindow: boolean,
+ isStartupLoading: boolean,
+ onboardingState: StartupOnboardingState,
+ vaultIsLoading: boolean,
+): boolean {
+ const readyVaultIsLoading = onboardingState.status === 'ready' && vaultIsLoading
+ return !isNoteWindow && (isStartupLoading || readyVaultIsLoading)
+}
+
+export function useStartupScreenState({
+ aiAgentsPromptVisible,
+ isNoteWindow,
+ onboardingState,
+ runtimeMissingVaultPath,
+ selectedVaultPath,
+ settingsLoaded,
+ showMcpSetupDialog,
+ telemetryConsent,
+ vaultIsLoading,
+ vaultSwitcher,
+}: UseStartupScreenStateArgs): StartupScreenState {
+ const shouldResumeFreshStartOnboarding = useMemo(
+ () => shouldResumeFreshStart(onboardingState, selectedVaultPath, vaultSwitcher),
+ [onboardingState, selectedVaultPath, vaultSwitcher],
+ )
+
+ const isStartupLoading = !isNoteWindow && onboardingState.status === 'loading'
+ const shouldShowStartupScreen = shouldShowStartupScreenForState({
+ aiAgentsPromptVisible,
+ isNoteWindow,
+ isStartupLoading,
+ onboardingState,
+ runtimeMissingVaultPath,
+ settingsLoaded,
+ shouldResumeFreshStartOnboarding,
+ showMcpSetupDialog,
+ telemetryConsent,
+ })
+ const vaultContentLoading = isVaultContentLoading(
+ isNoteWindow,
+ isStartupLoading,
+ onboardingState,
+ vaultIsLoading,
+ )
+
+ return {
+ isStartupLoading,
+ isVaultContentLoading: vaultContentLoading,
+ shouldResumeFreshStartOnboarding,
+ shouldShowStartupScreen,
+ }
+}
diff --git a/src/hooks/useVaultOpenedTelemetry.ts b/src/hooks/useVaultOpenedTelemetry.ts
new file mode 100644
index 00000000..d63d6129
--- /dev/null
+++ b/src/hooks/useVaultOpenedTelemetry.ts
@@ -0,0 +1,40 @@
+import { useEffect, useRef } from 'react'
+import { trackEvent } from '../lib/telemetry'
+import type { GitRepoState } from './useGitSetupState'
+
+interface UseVaultOpenedTelemetryArgs {
+ entryCount: number
+ gitRepoState: GitRepoState
+ resolvedPath: string
+}
+
+function shouldTrackVaultOpened(
+ entryCount: number,
+ gitRepoState: GitRepoState,
+ resolvedPath: string,
+ previousPath: string,
+): boolean {
+ const hasEntries = entryCount > 0
+ const gitStateKnown = gitRepoState !== 'checking'
+ const vaultChanged = resolvedPath !== previousPath
+
+ return hasEntries && gitStateKnown && vaultChanged
+}
+
+export function useVaultOpenedTelemetry({
+ entryCount,
+ gitRepoState,
+ resolvedPath,
+}: UseVaultOpenedTelemetryArgs): void {
+ const vaultOpenedRef = useRef('')
+
+ useEffect(() => {
+ if (!shouldTrackVaultOpened(entryCount, gitRepoState, resolvedPath, vaultOpenedRef.current)) return
+
+ vaultOpenedRef.current = resolvedPath
+ trackEvent('vault_opened', {
+ has_git: gitRepoState === 'ready' ? 1 : 0,
+ note_count: entryCount,
+ })
+ }, [entryCount, gitRepoState, resolvedPath])
+}
diff --git a/src/hooks/useVaultRenameDetection.ts b/src/hooks/useVaultRenameDetection.ts
new file mode 100644
index 00000000..f27e8220
--- /dev/null
+++ b/src/hooks/useVaultRenameDetection.ts
@@ -0,0 +1,62 @@
+import { useCallback, useEffect, useState } from 'react'
+import { invoke } from '@tauri-apps/api/core'
+import { isTauri } from '../mock-tauri'
+import type { DetectedRename } from '../components/RenameDetectedBanner'
+
+interface UseVaultRenameDetectionArgs {
+ vaultPath: string
+ reloadVault: () => unknown
+ setToastMessage: (message: string) => void
+}
+
+interface UseVaultRenameDetectionResult {
+ detectedRenames: DetectedRename[]
+ handleUpdateWikilinks: () => Promise
+ handleDismissRenames: () => void
+}
+
+export function useVaultRenameDetection({
+ vaultPath,
+ reloadVault,
+ setToastMessage,
+}: UseVaultRenameDetectionArgs): UseVaultRenameDetectionResult {
+ const [detectedRenames, setDetectedRenames] = useState([])
+
+ useEffect(() => {
+ if (!isTauri() || !vaultPath) return
+
+ const handleFocus = () => {
+ invoke('detect_renames', { args: { vaultPath } })
+ .then((renames) => {
+ if (renames.length > 0) setDetectedRenames(renames)
+ })
+ .catch((err) => console.warn('[vault] Git rename detection failed:', err))
+ }
+
+ window.addEventListener('focus', handleFocus)
+ return () => window.removeEventListener('focus', handleFocus)
+ }, [vaultPath])
+
+ const handleUpdateWikilinks = useCallback(async () => {
+ if (!isTauri()) return
+
+ try {
+ const count = await invoke('update_wikilinks_for_renames', {
+ args: { vaultPath, renames: detectedRenames },
+ })
+ setDetectedRenames([])
+ reloadVault()
+ setToastMessage(`Updated wikilinks in ${count} file${count !== 1 ? 's' : ''}`)
+ } catch (err) {
+ setToastMessage(`Failed to update wikilinks: ${err}`)
+ }
+ }, [detectedRenames, reloadVault, setToastMessage, vaultPath])
+
+ const handleDismissRenames = useCallback(() => setDetectedRenames([]), [])
+
+ return {
+ detectedRenames,
+ handleUpdateWikilinks,
+ handleDismissRenames,
+ }
+}