diff --git a/apps/desktop/src/main/__tests__/account-auth-ui.test.ts b/apps/desktop/src/main/__tests__/account-auth-ui.test.ts index c81f327697..9f01a346fb 100644 --- a/apps/desktop/src/main/__tests__/account-auth-ui.test.ts +++ b/apps/desktop/src/main/__tests__/account-auth-ui.test.ts @@ -232,7 +232,7 @@ describe('Account settings credential probe UI', () => { ); assert.match( page, - /const accountPageMountedRef = useRef\(false\);[\s\S]*useEffect\(\(\) => \{[\s\S]*accountPageMountedRef\.current = true;[\s\S]*return \(\) => \{[\s\S]*accountPageMountedRef\.current = false;[\s\S]*testingSlugRef\.current = null;/, + /const accountPageMountedRef = useMountedRef\(\);[\s\S]*useEffect\(\(\) => \{[\s\S]*return \(\) => \{[\s\S]*testingSlugRef\.current = null;/, 'Account page must release test ownership when Settings closes', ); assert.match( @@ -258,12 +258,12 @@ describe('Account settings credential probe UI', () => { assert.match( page, - /const accountPageMountedRef = useRef\(false\);/, + /const accountPageMountedRef = useMountedRef\(\);/, 'Account page must track mounted ownership for connection tests', ); assert.match( page, - /return \(\) => \{[\s\S]*accountPageMountedRef\.current = false;[\s\S]*testingSlugRef\.current = null;/, + /return \(\) => \{[\s\S]*testingSlugRef\.current = null;/, 'Account page cleanup must release an in-flight connection test owner', ); assert.match( diff --git a/apps/desktop/src/main/__tests__/artifact-pane-lifecycle-contract.test.ts b/apps/desktop/src/main/__tests__/artifact-pane-lifecycle-contract.test.ts index afdd601067..3c08b0bf3f 100644 --- a/apps/desktop/src/main/__tests__/artifact-pane-lifecycle-contract.test.ts +++ b/apps/desktop/src/main/__tests__/artifact-pane-lifecycle-contract.test.ts @@ -29,7 +29,7 @@ describe('ArtifactPane async lifecycle contract', () => { ); assert.match( src, - /const artifactPaneMountedRef = useRef\(true\)/, + /const artifactPaneMountedRef = useMountedRef\(\)/, 'ArtifactPane must track whether async artifact work still owns a mounted surface', ); assert.match( @@ -39,7 +39,7 @@ describe('ArtifactPane async lifecycle contract', () => { ); assert.match( src, - /useEffect\(\(\) => \{[\s\S]*artifactPaneMountedRef\.current = true;[\s\S]*return \(\) => \{[\s\S]*artifactPaneMountedRef\.current = false;[\s\S]*artifactListRequestSeqRef\.current \+= 1;[\s\S]*pendingArtifactListRetryRef\.current = false;[\s\S]*pendingArtifactActionRef\.current = null;[\s\S]*\};[\s\S]*\}, \[\]\)/, + /useEffect\(\(\) => \{[\s\S]*return \(\) => \{[\s\S]*artifactListRequestSeqRef\.current \+= 1;[\s\S]*pendingArtifactListRetryRef\.current = false;[\s\S]*pendingArtifactActionRef\.current = null;[\s\S]*\};[\s\S]*\}, \[\]\)/, 'ArtifactPane unmount must invalidate list responses, release pending owners, and be StrictMode replay safe', ); assert.match( @@ -181,7 +181,7 @@ describe('ArtifactPane async lifecycle contract', () => { assert.match(src, /const \[pendingArtifactAction, setPendingArtifactAction\] = useState\(null\)/); assert.match(src, /const pendingArtifactActionRef = useRef\(null\)/); - assert.match(src, /const artifactPaneMountedRef = useRef\(true\)/); + assert.match(src, /const artifactPaneMountedRef = useMountedRef\(\)/); assert.match(src, /const artifactActionBusy = pendingArtifactAction !== null/); assert.match( gateBlock, diff --git a/apps/desktop/src/main/__tests__/bot-settings-ui-contract.test.ts b/apps/desktop/src/main/__tests__/bot-settings-ui-contract.test.ts index 55fa929030..48654b3d0b 100644 --- a/apps/desktop/src/main/__tests__/bot-settings-ui-contract.test.ts +++ b/apps/desktop/src/main/__tests__/bot-settings-ui-contract.test.ts @@ -158,10 +158,10 @@ describe('Bot settings UI contract', () => { const refreshBlock = pageBlock.match(/async function refreshBotStatuses\(\)[\s\S]*?async function disconnectWechatLogin/)?.[0] ?? ''; const disconnectBlock = pageBlock.match(/async function disconnectWechatLogin\(\)[\s\S]*?const support =/)?.[0] ?? ''; - assert.match(pageBlock, /const botPageMountedRef = useRef\(false\)/); + assert.match(pageBlock, /const botPageMountedRef = useMountedRef\(\)/); assert.match( pageBlock, - /useEffect\(\(\) => \{[\s\S]*botPageMountedRef\.current = true;[\s\S]*return \(\) => \{[\s\S]*botPageMountedRef\.current = false;[\s\S]*pendingBotActionRef\.current = null;/, + /useEffect\(\(\) => \{[\s\S]*return \(\) => \{[\s\S]*pendingBotActionRef\.current = null;/, 'Bot settings page cleanup must release owned async actions when Settings closes', ); assert.match( @@ -249,11 +249,11 @@ describe('Bot settings UI contract', () => { assert.match(settings, /const fetchingQrRef = useRef\(false\)/, 'Direct WeChat scan-login refresh must keep a synchronous pending guard'); assert.match(settings, /const scanLoginPollingRef = useRef\(false\)/, 'Direct WeChat scan-login status polling must keep a synchronous in-flight guard'); assert.match(settings, /const scanLoginConfirmingRef = useRef\(false\)/, 'Direct WeChat scan-login confirmation must keep a synchronous owner guard'); - assert.match(settings, /const scanLoginMountedRef = useRef\(false\)/, 'Direct WeChat scan-login modal must track mounted ownership'); + assert.match(settings, /const scanLoginMountedRef = useMountedRef\(\)/, 'Direct WeChat scan-login modal must track mounted ownership'); assert.match(settings, /const scanLoginFetchTicketRef = useRef\(0\)/, 'Direct WeChat scan-login modal must invalidate stale QR fetches across remounts'); assert.match( settings, - /useEffect\(\(\) => \{[\s\S]*scanLoginMountedRef\.current = true;[\s\S]*void fetchQr\(\);[\s\S]*return \(\) => \{[\s\S]*scanLoginMountedRef\.current = false;[\s\S]*scanLoginFetchTicketRef\.current \+= 1;[\s\S]*fetchingQrRef\.current = false;[\s\S]*scanLoginPollingRef\.current = false;[\s\S]*scanLoginConfirmingRef\.current = false;/, + /useEffect\(\(\) => \{[\s\S]*void fetchQr\(\);[\s\S]*return \(\) => \{[\s\S]*scanLoginFetchTicketRef\.current \+= 1;[\s\S]*fetchingQrRef\.current = false;[\s\S]*scanLoginPollingRef\.current = false;[\s\S]*scanLoginConfirmingRef\.current = false;/, 'Direct WeChat scan-login modal must release QR, poll, and confirmation ownership when closed', ); assert.match(settings, /if \(fetchingQrRef\.current\) return;[\s\S]*fetchingQrRef\.current = true;[\s\S]*const ticket = \+\+scanLoginFetchTicketRef\.current;[\s\S]*setStatus\('fetching'\)/, 'Direct WeChat scan-login QR fetch must block rapid duplicate refreshes before React rerenders'); diff --git a/apps/desktop/src/main/__tests__/composer-new-chat-model-picker-contract.test.ts b/apps/desktop/src/main/__tests__/composer-new-chat-model-picker-contract.test.ts index 114f7d0280..3b964022d3 100644 --- a/apps/desktop/src/main/__tests__/composer-new-chat-model-picker-contract.test.ts +++ b/apps/desktop/src/main/__tests__/composer-new-chat-model-picker-contract.test.ts @@ -90,6 +90,7 @@ describe('home composer new-chat model picker', () => { const renderer = await readRendererShellSources([ 'app-shell.tsx', 'app-shell-chat-actions.ts', + 'use-shell-chat-model.ts', ]); assert.match( @@ -123,6 +124,7 @@ describe('home composer new-chat model picker', () => { const renderer = await readRendererShellSources([ 'app-shell.tsx', 'app-shell-chat-actions.ts', + 'use-shell-chat-model.ts', ]); assert.match( diff --git a/apps/desktop/src/main/__tests__/composer-send-guard.test.ts b/apps/desktop/src/main/__tests__/composer-send-guard.test.ts index b66967759b..a0c1d0b9ff 100644 --- a/apps/desktop/src/main/__tests__/composer-send-guard.test.ts +++ b/apps/desktop/src/main/__tests__/composer-send-guard.test.ts @@ -55,10 +55,10 @@ describe('composer send guard', () => { const composerBlock = source.match(/export const Composer = forwardRef[\s\S]*$/)?.[0] ?? ''; const sendCurrent = source.match(/async function sendCurrent\(\) \{[\s\S]*?\n \}/)?.[0] ?? ''; - assert.match(composerBlock, /const composerMountedRef = useRef\(true\)/); + assert.match(composerBlock, /const composerMountedRef = useMountedRef\(\)/); assert.match( composerBlock, - /useEffect\(\(\) => \{\s*composerMountedRef\.current = true;[\s\S]*?return \(\) => \{\s*composerMountedRef\.current = false;\s*sendPendingRef\.current = false;\s*importActionOwnerRef\.current\?\.reset\(\);\s*\};\s*\}, \[\]\)/, + /useEffect\(\(\) => \{\s*return \(\) => \{\s*sendPendingRef\.current = false;\s*importActionOwnerRef\.current\?\.reset\(\);\s*\};\s*\}, \[\]\)/, 'Composer must release send/import pending owners when it unmounts or StrictMode replays cleanup', ); assert.match( diff --git a/apps/desktop/src/main/__tests__/daily-review-copy-feedback-contract.test.ts b/apps/desktop/src/main/__tests__/daily-review-copy-feedback-contract.test.ts index a48e48e943..fd9cf1b5c7 100644 --- a/apps/desktop/src/main/__tests__/daily-review-copy-feedback-contract.test.ts +++ b/apps/desktop/src/main/__tests__/daily-review-copy-feedback-contract.test.ts @@ -120,11 +120,11 @@ describe('Daily Review copy feedback contract', () => { assert.ok(gateBlock, 'runDailyReviewAction gate not found in DailyReviewPanel'); assert.match(panelBlock, /const \[pendingDailyReviewAction, setPendingDailyReviewAction\] = useState\(null\)/); - assert.match(panelBlock, /const dailyReviewMountedRef = useRef\(true\)/); + assert.match(panelBlock, /const dailyReviewMountedRef = useMountedRef\(\)/); assert.match(panelBlock, /const pendingDailyReviewActionRef = useRef\(null\)/); assert.match( panelBlock, - /useEffect\(\(\) => \{\s*dailyReviewMountedRef\.current = true;[\s\S]*?return \(\) => \{\s*dailyReviewMountedRef\.current = false;\s*pendingDailyReviewActionRef\.current = null;\s*(?:archiveLoadRequestRef\.current \+= 1;\s*)?\};\s*\}, \[\]\)/, + /useEffect\(\(\) => \{\s*return \(\) => \{\s*pendingDailyReviewActionRef\.current = null;\s*(?:archiveLoadRequestRef\.current \+= 1;\s*)?\};\s*\}, \[\]\)/, 'Daily Review export pending ownership must be released when the main panel unmounts or StrictMode replays cleanup', ); assert.match(panelBlock, /const dailyReviewActionBusy = pendingDailyReviewAction !== null/); @@ -194,7 +194,7 @@ describe('Daily Review copy feedback contract', () => { assert.match(panelBlock, /const archiveLoadRequestRef = useRef\(0\)/); assert.match( panelBlock, - /return \(\) => \{\s*dailyReviewMountedRef\.current = false;\s*pendingDailyReviewActionRef\.current = null;\s*archiveLoadRequestRef\.current \+= 1;\s*\};/, + /return \(\) => \{\s*pendingDailyReviewActionRef\.current = null;\s*archiveLoadRequestRef\.current \+= 1;\s*\};/, 'Daily Review archive loads must be invalidated when the panel unmounts', ); assert.match( diff --git a/apps/desktop/src/main/__tests__/first-run-task-suggestions.test.ts b/apps/desktop/src/main/__tests__/first-run-task-suggestions.test.ts index b1b22c8fb5..cda4fd2f07 100644 --- a/apps/desktop/src/main/__tests__/first-run-task-suggestions.test.ts +++ b/apps/desktop/src/main/__tests__/first-run-task-suggestions.test.ts @@ -104,7 +104,7 @@ describe('FIRST_RUN_TASK_SUGGESTIONS', () => { const gateBlock = readyBlock.match(/const runImportAction = useCallback[\s\S]*?const importActionBusy/)?.[0] ?? ''; assert.match(readyBlock, /const \[pendingImportAction, setPendingImportAction\] = useState\(null\)/); - assert.match(readyBlock, /const readyHeroMountedRef = useRef\(true\)/); + assert.match(readyBlock, /const readyHeroMountedRef = useMountedRef\(\)/); assert.match(readyBlock, /const importActionOwnerRef = useRef \| null>\(null\)/); assert.match(readyBlock, /importActionOwnerRef\.current = createChatInputActionOwner/); assert.match(readyBlock, /const importActionBusy = pendingImportAction !== null/); @@ -181,11 +181,11 @@ describe('FIRST_RUN_TASK_SUGGESTIONS', () => { assert.match(refreshBlock, /workspaceInstructions\.getState\(\)\.then\([\s\S]*?\.catch\(\(error\) => \{[\s\S]*setWorkspaceInstructionCount\(null\);[\s\S]*handleProbeFailure\(error\)/); assert.doesNotMatch(refreshBlock, /catch[\s\S]*setSettings\(null\)|catch[\s\S]*setPlanReminders\(\[\]\)|catch[\s\S]*setWorkspaceInstructionCount\(0\)/); assert.match(source, /const \[statusRefreshPending, setStatusRefreshPending\] = useState\(false\)/); - assert.match(source, /const checklistMountedRef = useRef\(true\)/); + assert.match(source, /const checklistMountedRef = useMountedRef\(\)/); assert.match(source, /const statusRefreshPendingRef = useRef\(false\)/); assert.match( source, - /useEffect\(\(\) => \{[\s\S]*checklistMountedRef\.current = true;[\s\S]*return \(\) => \{[\s\S]*checklistMountedRef\.current = false;[\s\S]*statusRefreshPendingRef\.current = false;[\s\S]*\};[\s\S]*\}, \[\]\)/, + /useEffect\(\(\) => \{[\s\S]*return \(\) => \{[\s\S]*statusRefreshPendingRef\.current = false;[\s\S]*\};[\s\S]*\}, \[\]\)/, 'first-run checklist must restore mounted state during StrictMode replay and release refresh ownership on unmount', ); assert.match(source, /const isChecklistUnmounted = useCallback\(\(\) => !checklistMountedRef\.current, \[\]\)/); diff --git a/apps/desktop/src/main/__tests__/model-oauth-section-contract.test.ts b/apps/desktop/src/main/__tests__/model-oauth-section-contract.test.ts index 82cd6fd6ad..d2977df4de 100644 --- a/apps/desktop/src/main/__tests__/model-oauth-section-contract.test.ts +++ b/apps/desktop/src/main/__tests__/model-oauth-section-contract.test.ts @@ -85,7 +85,7 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO assert.ok(reloadMatch, 'ProvidersPanel reload() must exist'); assert.match( panel, - /const providersPanelMountedRef = useRef\(false\);[\s\S]*const providersReloadTicketRef = useRef\(0\);[\s\S]*const providerPageLifecycleRef = useRef\(0\);/, + /const providersPanelMountedRef = useMountedRef\(\);[\s\S]*const providersReloadTicketRef = useRef\(0\);[\s\S]*const providerPageLifecycleRef = useRef\(0\);/, 'ProvidersPanel reloads must track mounted state and latest request ownership', ); assert.match( @@ -100,7 +100,7 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO ); assert.match( panel, - /return \(\) => \{[\s\S]*providersPanelMountedRef\.current = false;[\s\S]*providersReloadTicketRef\.current \+= 1;[\s\S]*unsubscribe\?\.\(\);/, + /return \(\) => \{[\s\S]*providersReloadTicketRef\.current \+= 1;[\s\S]*unsubscribe\?\.\(\);/, 'ProvidersPanel cleanup must invalidate in-flight reloads and unsubscribe from connection events', ); assert.match( @@ -243,7 +243,7 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO ); assert.match( addForm, - /const addProviderMountedRef = useRef\(false\)[\s\S]*useEffect\(\(\) => \{[\s\S]*addProviderMountedRef\.current = true;[\s\S]*return \(\) => \{[\s\S]*addProviderMountedRef\.current = false;[\s\S]*busyRef\.current = false;[\s\S]*\};[\s\S]*\}, \[\]\);/, + /const addProviderMountedRef = useMountedRef\(\)[\s\S]*useEffect\(\(\) => \{[\s\S]*return \(\) => \{[\s\S]*busyRef\.current = false;[\s\S]*\};[\s\S]*\}, \[\]\);/, 'AddProviderForm must track its own sheet lifetime so pending create continuations cannot write after overlay close', ); assert.match( @@ -720,12 +720,12 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO assert.match( detail, - /const connectionDetailMountedRef = useRef\(false\);[\s\S]*const connectionDetailLifecycleRef = useRef\(0\);/, + /const connectionDetailMountedRef = useMountedRef\(\);[\s\S]*const connectionDetailLifecycleRef = useRef\(0\);/, 'ConnectionDetail must track mounted/lifecycle ownership', ); assert.match( detail, - /useEffect\(\(\) => \{[\s\S]*connectionDetailMountedRef\.current = true;[\s\S]*connectionDetailLifecycleRef\.current \+= 1;[\s\S]*return \(\) => \{[\s\S]*connectionDetailMountedRef\.current = false;[\s\S]*connectionDetailLifecycleRef\.current \+= 1;[\s\S]*busyRef\.current = false;[\s\S]*testingRef\.current = false;[\s\S]*fetchingModelsRef\.current = false;[\s\S]*settingDefaultRef\.current = false;[\s\S]*deletingRef\.current = false;[\s\S]*\};[\s\S]*\}, \[connection\.slug\]\);/, + /useEffect\(\(\) => \{[\s\S]*connectionDetailLifecycleRef\.current \+= 1;[\s\S]*return \(\) => \{[\s\S]*connectionDetailLifecycleRef\.current \+= 1;[\s\S]*busyRef\.current = false;[\s\S]*testingRef\.current = false;[\s\S]*fetchingModelsRef\.current = false;[\s\S]*settingDefaultRef\.current = false;[\s\S]*deletingRef\.current = false;[\s\S]*\};[\s\S]*\}, \[connection\.slug\]\);/, 'ConnectionDetail cleanup must release every pending action owner on close or provider switch', ); assert.match( @@ -938,7 +938,7 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO assert.match( section, - /const modelOAuthMountedRef = useRef\(false\);[\s\S]*const modelOAuthRefreshTicketRef = useRef\(0\);/, + /const modelOAuthMountedRef = useMountedRef\(\);[\s\S]*const modelOAuthRefreshTicketRef = useRef\(0\);/, 'ModelOAuthSection must keep mounted and latest-refresh ownership refs', ); assert.match( @@ -948,7 +948,7 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO ); assert.match( section, - /useEffect\(\(\) => \{[\s\S]*modelOAuthMountedRef\.current = true;[\s\S]*void refreshAllCards\(\);[\s\S]*return \(\) => \{[\s\S]*modelOAuthMountedRef\.current = false;[\s\S]*modelOAuthRefreshTicketRef\.current \+= 1;[\s\S]*\};[\s\S]*\}, \[\]\);/, + /useEffect\(\(\) => \{[\s\S]*void refreshAllCards\(\);[\s\S]*return \(\) => \{[\s\S]*modelOAuthRefreshTicketRef\.current \+= 1;[\s\S]*\};[\s\S]*\}, \[\]\);/, 'OAuth card refresh must invalidate in-flight requests on unmount', ); assert.match( @@ -1054,7 +1054,7 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO ); assert.match( hook, - /const oauthLoginFlowMountedRef = useRef\(false\)/, + /const oauthLoginFlowMountedRef = useMountedRef\(\)/, 'shared OAuth flow must own mounted state before writing async feedback', ); assert.match( @@ -1069,7 +1069,7 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO ); assert.match( hook, - /useEffect\(\(\) => \{[\s\S]*oauthLoginFlowMountedRef\.current = true;[\s\S]*void refresh\(\);[\s\S]*return \(\) => \{[\s\S]*oauthLoginFlowMountedRef\.current = false;[\s\S]*pendingGuard\.finish\(\);[\s\S]*teardownPendingAuthorization\(authRequestIdRef, \(id\) => void bridge\.cancelAuthorization\(id\)\);[\s\S]*\};[\s\S]*\}, \[\]\);/, + /useEffect\(\(\) => \{[\s\S]*void refresh\(\);[\s\S]*return \(\) => \{[\s\S]*pendingGuard\.finish\(\);[\s\S]*teardownPendingAuthorization\(authRequestIdRef, \(id\) => void bridge\.cancelAuthorization\(id\)\);[\s\S]*\};[\s\S]*\}, \[\]\);/, 'shared OAuth flow cleanup must invalidate async feedback and cancel pending authorization', ); assert.match( @@ -1178,7 +1178,7 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO ); assert.match( claudeCard, - /return \(\) => \{[\s\S]*claudeCardMountedRef\.current = false;[\s\S]*const pendingAuthRequestId = claudeAuthRequestIdRef\.current;[\s\S]*claudeAuthRequestIdRef\.current = null;[\s\S]*if \(pendingAuthRequestId\) void window\.maka\.claudeSubscription\.cancelAuthorization\(pendingAuthRequestId\);[\s\S]*\};/, + /return \(\) => \{[\s\S]*const pendingAuthRequestId = claudeAuthRequestIdRef\.current;[\s\S]*claudeAuthRequestIdRef\.current = null;[\s\S]*if \(pendingAuthRequestId\) void window\.maka\.claudeSubscription\.cancelAuthorization\(pendingAuthRequestId\);[\s\S]*\};/, 'closing the Claude OAuth modal mid-login must cancel the pending auth request', ); assert.match( diff --git a/apps/desktop/src/main/__tests__/office-documents-capability.test.ts b/apps/desktop/src/main/__tests__/office-documents-capability.test.ts index 382688336a..fe849edaae 100644 --- a/apps/desktop/src/main/__tests__/office-documents-capability.test.ts +++ b/apps/desktop/src/main/__tests__/office-documents-capability.test.ts @@ -57,10 +57,10 @@ describe('Office document capability contract', () => { assert.doesNotMatch(settings, /
/); assert.match(settings, /copyingOfficeCliInstallRef\.current/, 'OfficeCLI install copy action must have a ref-backed double-click guard'); assert.match(settings, /if \(copyingOfficeCliInstallRef\.current\) return;/); - assert.match(settings, /const capabilityRowMountedRef = useRef\(false\);/); + assert.match(settings, /const capabilityRowMountedRef = useMountedRef\(\);/); assert.match( settings, - /useEffect\(\(\) => \{[\s\S]*capabilityRowMountedRef\.current = true;[\s\S]*return \(\) => \{[\s\S]*capabilityRowMountedRef\.current = false;[\s\S]*copyingOfficeCliInstallRef\.current = false;/, + /useEffect\(\(\) => \{[\s\S]*return \(\) => \{[\s\S]*copyingOfficeCliInstallRef\.current = false;/, 'OfficeCLI install copy action must release ownership when its capability row unmounts', ); assert.match(settings, /disabled=\{copyingOfficeCliInstall\}/); diff --git a/apps/desktop/src/main/__tests__/onboarding-hero-copy.test.ts b/apps/desktop/src/main/__tests__/onboarding-hero-copy.test.ts index 49b8fe2574..ec3f792097 100644 --- a/apps/desktop/src/main/__tests__/onboarding-hero-copy.test.ts +++ b/apps/desktop/src/main/__tests__/onboarding-hero-copy.test.ts @@ -411,10 +411,10 @@ describe('OnboardingHero Quick Chat draft lifecycle', () => { assert.match(readyBlock, /const \[submitPending, setSubmitPending\] = useState\(false\)/); assert.match(readyBlock, /const submitPendingRef = useRef\(false\)/); - assert.match(readyBlock, /const readyHeroMountedRef = useRef\(true\)/); + assert.match(readyBlock, /const readyHeroMountedRef = useMountedRef\(\)/); assert.match( readyBlock, - /useEffect\(\(\) => \{[\s\S]*readyHeroMountedRef\.current = true;[\s\S]*return \(\) => \{[\s\S]*readyHeroMountedRef\.current = false;[\s\S]*submitPendingRef\.current = false;[\s\S]*importActionOwnerRef\.current\?\.reset\(\);[\s\S]*\};[\s\S]*\}, \[\]\)/, + /useEffect\(\(\) => \{[\s\S]*return \(\) => \{[\s\S]*submitPendingRef\.current = false;[\s\S]*importActionOwnerRef\.current\?\.reset\(\);[\s\S]*\};[\s\S]*\}, \[\]\)/, 'ReadyEmptyHero must clear async pending owners on unmount and restore mounted state during StrictMode replay', ); assert.match(readyBlock, /const quickChatBusy = props\.quickChatPending \|\| submitPending/); diff --git a/apps/desktop/src/main/__tests__/open-gateway-settings-contract.test.ts b/apps/desktop/src/main/__tests__/open-gateway-settings-contract.test.ts index 5a72a05de3..4266309115 100644 --- a/apps/desktop/src/main/__tests__/open-gateway-settings-contract.test.ts +++ b/apps/desktop/src/main/__tests__/open-gateway-settings-contract.test.ts @@ -99,7 +99,7 @@ describe('Open Gateway Settings endpoint contract', () => { assert.match( gatewayBlock, - /const openGatewayMountedRef = useRef\(false\);[\s\S]*openGatewayMountedRef\.current = true;[\s\S]*return \(\) => \{[\s\S]*openGatewayMountedRef\.current = false;[\s\S]*copyingGatewayActionRef\.current = null;/, + /const openGatewayMountedRef = useMountedRef\(\);[\s\S]*return \(\) => \{[\s\S]*copyingGatewayActionRef\.current = null;/, 'Open Gateway Settings must release copy ownership when the page closes', ); assert.match( diff --git a/apps/desktop/src/main/__tests__/permission-response-guard.test.ts b/apps/desktop/src/main/__tests__/permission-response-guard.test.ts index 1fc37d6c2a..63ece3181c 100644 --- a/apps/desktop/src/main/__tests__/permission-response-guard.test.ts +++ b/apps/desktop/src/main/__tests__/permission-response-guard.test.ts @@ -17,12 +17,12 @@ describe('permission prompt response guard', () => { assert.match(prompt, /const \[responsePending, setResponsePending\] = useState\(false\);/); assert.match(prompt, /const responsePendingRef = useRef\(false\);/); - assert.match(prompt, /const permissionMountedRef = useRef\(true\);/); + assert.match(prompt, /const permissionMountedRef = useMountedRef\(\);/); assert.match(prompt, /const activePermissionRequestIdRef = useRef\(props\.request\.requestId\);/); assert.match( prompt, - /useEffect\(\(\) => \{\s*permissionMountedRef\.current = true;\s*return \(\) => \{\s*permissionMountedRef\.current = false;\s*\};\s*\}, \[\]\)/, - 'permission response settlement must not update state after the prompt unmounts', + /if \(permissionMountedRef\.current\) setResponsePending\(false\);/, + 'permission response settlement must not update state after the prompt unmounts; mount state is owned by the shared useMountedRef hook', ); assert.match( prompt, diff --git a/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts b/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts index 3ef07bc56f..e756b6cbad 100644 --- a/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts @@ -220,7 +220,7 @@ describe('permission response IPC boundary', () => { const components = await readFile(componentsPath, 'utf8'); const submit = components.match(/async function submit\(decision:[\s\S]*?\n \}/); assert.ok(submit, 'PermissionPrompt submit() must be async'); - assert.match(components, /const permissionMountedRef = useRef\(true\);/); + assert.match(components, /const permissionMountedRef = useMountedRef\(\);/); assert.match(components, /const activePermissionRequestIdRef = useRef\(props\.request\.requestId\);/); assert.match(components, /activePermissionRequestIdRef\.current = props\.request\.requestId;/); assert.match(submit[0], /const requestId = props\.request\.requestId;/); diff --git a/apps/desktop/src/main/__tests__/personalization-sync-contract.test.ts b/apps/desktop/src/main/__tests__/personalization-sync-contract.test.ts index 94a092427c..bb8a6a3c13 100644 --- a/apps/desktop/src/main/__tests__/personalization-sync-contract.test.ts +++ b/apps/desktop/src/main/__tests__/personalization-sync-contract.test.ts @@ -192,12 +192,12 @@ describe('Personalization form state sync (PR-PERSONALIZATION-SYNC-0)', () => { assert.match( page, - /const personalizationMountedRef = useRef\(false\)/, + /const personalizationMountedRef = useMountedRef\(\)/, 'Personalization save must track page ownership separately from React pending state', ); assert.match( page, - /useEffect\(\(\) => \{[\s\S]*personalizationMountedRef\.current = true;[\s\S]*return \(\) => \{[\s\S]*personalizationMountedRef\.current = false;/, + /useEffect\(\(\) => \{[\s\S]*return \(\) => \{[\s\S]*persistTicketRef\.current \+= 1;/, 'Personalization cleanup must release page ownership when Settings closes', ); // Cleanup must invalidate any in-flight save's late apply (bump ticket) diff --git a/apps/desktop/src/main/__tests__/plan-reminder-panel-contract.test.ts b/apps/desktop/src/main/__tests__/plan-reminder-panel-contract.test.ts index 4528ff778e..57798661e4 100644 --- a/apps/desktop/src/main/__tests__/plan-reminder-panel-contract.test.ts +++ b/apps/desktop/src/main/__tests__/plan-reminder-panel-contract.test.ts @@ -23,7 +23,7 @@ describe('Plan Reminder panel async action contract', () => { assert.match(panelBlock, /const refreshPendingRef = useRef\(false\)/); assert.match( panelBlock, - /return \(\) => \{\s*planReminderMountedRef\.current = false;\s*submitPendingRef\.current = false;\s*refreshPendingRef\.current = false;\s*pendingActionKeysRef\.current = new Set\(\);/, + /return \(\) => \{\s*submitPendingRef\.current = false;\s*refreshPendingRef\.current = false;\s*pendingActionKeysRef\.current = new Set\(\);/, 'Plan Reminder pending form/refresh owners must be released when the panel unmounts', ); diff --git a/apps/desktop/src/main/__tests__/renderer-shell-source-helpers.ts b/apps/desktop/src/main/__tests__/renderer-shell-source-helpers.ts index dc4569a8c9..da25d9903c 100644 --- a/apps/desktop/src/main/__tests__/renderer-shell-source-helpers.ts +++ b/apps/desktop/src/main/__tests__/renderer-shell-source-helpers.ts @@ -34,6 +34,8 @@ const sourcePaths = [ 'use-project-context.ts', 'use-module-data.ts', 'use-shell-connections.ts', + 'use-shell-chat-model.ts', + 'use-shell-live-turn.ts', 'app-shell-visual-smoke.ts', 'cached-theme-bootstrap.ts', 'chat-model-selection.ts', diff --git a/apps/desktop/src/main/__tests__/renderer-startup-fail-soft-contract.test.ts b/apps/desktop/src/main/__tests__/renderer-startup-fail-soft-contract.test.ts index 3c0de217e2..aec8722f8d 100644 --- a/apps/desktop/src/main/__tests__/renderer-startup-fail-soft-contract.test.ts +++ b/apps/desktop/src/main/__tests__/renderer-startup-fail-soft-contract.test.ts @@ -116,11 +116,11 @@ describe('renderer startup fail-soft contract', () => { const reloadSettingsBlock = modalBlock.match(/async function reloadSettings\(\)[\s\S]*?async function updateSettings/)?.[0] ?? ''; const reloadUsageBlock = modalBlock.match(/async function reloadUsage[\s\S]*?useEffect\(\(\) => \{[\s\S]*?void reloadSettings/)?.[0] ?? ''; - assert.match(modalBlock, /const settingsModalMountedRef = useRef\(false\);/); + assert.match(modalBlock, /const settingsModalMountedRef = useMountedRef\(\);/); assert.match(modalBlock, /const settingsReloadTicketRef = useRef\(0\);/); assert.match( modalBlock, - /useEffect\(\(\) => \{[\s\S]*settingsModalMountedRef\.current = true;[\s\S]*return \(\) => \{[\s\S]*settingsModalMountedRef\.current = false;[\s\S]*settingsReloadTicketRef\.current \+= 1;[\s\S]*settingsUpdateTicketRef\.current \+= 1;[\s\S]*usageReloadTicketRef\.current \+= 1;[\s\S]*\};[\s\S]*\}, \[\]\);/, + /useEffect\(\(\) => \{[\s\S]*return \(\) => \{[\s\S]*settingsReloadTicketRef\.current \+= 1;[\s\S]*settingsUpdateTicketRef\.current \+= 1;[\s\S]*usageReloadTicketRef\.current \+= 1;[\s\S]*\};[\s\S]*\}, \[\]\);/, 'Settings root async work must be invalidated on close and StrictMode effect cleanup', ); assert.match(reloadSettingsBlock, /try \{[\s\S]*window\.maka\.settings\.get\(\)/); diff --git a/apps/desktop/src/main/__tests__/renderer-utility-primitives-contract.test.ts b/apps/desktop/src/main/__tests__/renderer-utility-primitives-contract.test.ts index 094ace66c8..9cc61c1daf 100644 --- a/apps/desktop/src/main/__tests__/renderer-utility-primitives-contract.test.ts +++ b/apps/desktop/src/main/__tests__/renderer-utility-primitives-contract.test.ts @@ -20,7 +20,7 @@ describe('renderer utility surfaces use shared UI primitives', () => { /const result = normalizeBrowserAddressInput\(address\);[\s\S]*if \(!result\.ok\) \{[\s\S]*toast\.error\('无法打开地址', browserAddressFailureCopy\(result\.reason\)\);[\s\S]*return;[\s\S]*const ownerSessionId = sessionId;[\s\S]*window\.maka\.browser\.navigate\(ownerSessionId, result\.url\)/, 'BrowserPanel must validate addresses with the shared helper before invoking browser navigation', ); - assert.match(source, /const browserPanelMountedRef = useRef\(false\)/); + assert.match(source, /const browserPanelMountedRef = useMountedRef\(\)/); assert.match(source, /const browserPanelSessionIdRef = useRef\(sessionId\)/); assert.match(source, /browserPanelSessionIdRef\.current = sessionId/); assert.match( diff --git a/apps/desktop/src/main/__tests__/search-modal-lifecycle-contract.test.ts b/apps/desktop/src/main/__tests__/search-modal-lifecycle-contract.test.ts index 8f54df1928..aaff3c8cc3 100644 --- a/apps/desktop/src/main/__tests__/search-modal-lifecycle-contract.test.ts +++ b/apps/desktop/src/main/__tests__/search-modal-lifecycle-contract.test.ts @@ -373,10 +373,10 @@ describe('SearchModal lifecycle contract (PR-SIDEBAR-IA-0 Phase 3 P0 fixup)', () it('closing the modal invalidates already-started search requests before they set state', async () => { const searchModal = await readFile(SEARCH_MODAL_PATH, 'utf8'); - assert.match(searchModal, /const searchMountedRef = useRef\(true\)/, 'SearchModal must track whether the conditionally mounted dialog is still alive.'); + assert.match(searchModal, /const searchMountedRef = useMountedRef\(\)/, 'SearchModal must track whether the conditionally mounted dialog is still alive.'); assert.match( searchModal, - /useEffect\(\(\) => \{\s*searchMountedRef\.current = true;\s*return \(\) => \{\s*searchMountedRef\.current = false;\s*ticketRef\.current \+= 1;\s*\};\s*\}, \[\]\)/, + /useEffect\(\(\) => \{\s*return \(\) => \{\s*ticketRef\.current \+= 1;\s*\};\s*\}, \[\]\)/, 'SearchModal unmount cleanup must invalidate in-flight searches, including requests that already passed the debounce timer.', ); assert.match( diff --git a/apps/desktop/src/main/__tests__/session-row-actions-fail-soft-contract.test.ts b/apps/desktop/src/main/__tests__/session-row-actions-fail-soft-contract.test.ts index 266fc1e688..3781b82158 100644 --- a/apps/desktop/src/main/__tests__/session-row-actions-fail-soft-contract.test.ts +++ b/apps/desktop/src/main/__tests__/session-row-actions-fail-soft-contract.test.ts @@ -84,7 +84,7 @@ describe('session row actions fail soft', () => { assert.match(ui, /onToggleFlag\(sessionId: string, next: boolean\): void \| Promise;/); assert.match(ui, /onDelete\(sessionId: string\): void \| Promise;/); assert.match(ui, /const \[pendingAction,\s*setPendingAction\] = useState\(null\);/); - assert.match(ui, /const rowMountedRef = useRef\(true\);/); + assert.match(ui, /const rowMountedRef = useMountedRef\(\);/); assert.match(ui, /const pendingActionRef = useRef\(null\);/); assert.match( ui, @@ -92,7 +92,7 @@ describe('session row actions fail soft', () => { ); assert.match( ui, - /useEffect\(\(\) => \{\s*rowMountedRef\.current = true;[\s\S]*?return \(\) => \{\s*rowMountedRef\.current = false;\s*pendingActionRef\.current = null;\s*\};\s*\}, \[\]\)/, + /useEffect\(\(\) => \{\s*return \(\) => \{\s*pendingActionRef\.current = null;\s*\};\s*\}, \[\]\)/, 'SessionRow must release pending ownership when archive/delete/filter changes unmount the row', ); assert.match( diff --git a/apps/desktop/src/main/__tests__/session-sticky-model-contract.test.ts b/apps/desktop/src/main/__tests__/session-sticky-model-contract.test.ts index 2b357ae218..25ae46b00a 100644 --- a/apps/desktop/src/main/__tests__/session-sticky-model-contract.test.ts +++ b/apps/desktop/src/main/__tests__/session-sticky-model-contract.test.ts @@ -137,12 +137,12 @@ describe('PR-SESSION-STICKY-MODEL-0 contract', () => { assert.match(ui, /const \[localPending,\s*setLocalPending\] = useState\(false\);/); assert.match(ui, /const pendingRef = useRef\(false\);/); assert.match(ui, /const pending = props\.pending \|\| localPending;/); - assert.match(ui, /const modelSwitcherMountedRef = useRef\(true\);/); + assert.match(ui, /const modelSwitcherMountedRef = useMountedRef\(\);/); assert.match(ui, /const pendingModelChangeRef = useRef<\{ sessionId: string; token: number \} \| null>\(null\);/); assert.match(ui, /const pendingModelChangeTokenRef = useRef\(0\);/); assert.match( ui, - /useEffect\(\(\) => \{[\s\S]*modelSwitcherMountedRef\.current = true;[\s\S]*return \(\) => \{[\s\S]*modelSwitcherMountedRef\.current = false;[\s\S]*pendingModelChangeRef\.current = null;[\s\S]*pendingModelChangeTokenRef\.current \+= 1;[\s\S]*pendingRef\.current = false;[\s\S]*\};[\s\S]*\}, \[\]\);/, + /useEffect\(\(\) => \{[\s\S]*return \(\) => \{[\s\S]*pendingModelChangeRef\.current = null;[\s\S]*pendingModelChangeTokenRef\.current \+= 1;[\s\S]*pendingRef\.current = false;[\s\S]*\};[\s\S]*\}, \[\]\);/, 'model switcher must release pending ownership when the chat header unmounts', ); assert.match( diff --git a/apps/desktop/src/main/__tests__/settings-app-info-contract.test.ts b/apps/desktop/src/main/__tests__/settings-app-info-contract.test.ts index 0260623510..67e271500c 100644 --- a/apps/desktop/src/main/__tests__/settings-app-info-contract.test.ts +++ b/apps/desktop/src/main/__tests__/settings-app-info-contract.test.ts @@ -72,10 +72,10 @@ describe('Settings app-info loading contract', () => { assert.match(dataBlock, /const \[pendingDataAction, setPendingDataAction\] = useState\(null\)/); assert.match(dataBlock, /const pendingDataActionRef = useRef\(null\)/); - assert.match(dataBlock, /const dataPageMountedRef = useRef\(false\)/); + assert.match(dataBlock, /const dataPageMountedRef = useMountedRef\(\)/); assert.match( dataBlock, - /useEffect\(\(\) => \{[\s\S]*dataPageMountedRef\.current = true;[\s\S]*return \(\) => \{[\s\S]*dataPageMountedRef\.current = false;[\s\S]*pendingDataActionRef\.current = null;[\s\S]*\};[\s\S]*\}, \[toast\]\);/, + /useEffect\(\(\) => \{[\s\S]*return \(\) => \{[\s\S]*pendingDataActionRef\.current = null;[\s\S]*\};[\s\S]*\}, \[toast\]\);/, 'Data page actions must be invalidated when the page unmounts', ); assert.match( @@ -154,10 +154,10 @@ describe('Settings app-info loading contract', () => { assert.match(aboutBlock, /const \[copyingEnvSummary, setCopyingEnvSummary\] = useState\(false\)/); assert.match(aboutBlock, /const copyingEnvSummaryRef = useRef\(false\)/); - assert.match(aboutBlock, /const aboutPageMountedRef = useRef\(false\)/); + assert.match(aboutBlock, /const aboutPageMountedRef = useMountedRef\(\)/); assert.match( aboutBlock, - /useEffect\(\(\) => \{[\s\S]*aboutPageMountedRef\.current = true;[\s\S]*return \(\) => \{[\s\S]*aboutPageMountedRef\.current = false;[\s\S]*copyingEnvSummaryRef\.current = false;[\s\S]*\};[\s\S]*\}, \[toast\]\);/, + /useEffect\(\(\) => \{[\s\S]*return \(\) => \{[\s\S]*copyingEnvSummaryRef\.current = false;[\s\S]*\};[\s\S]*\}, \[toast\]\);/, 'About page copy actions must be invalidated when the page unmounts', ); assert.match( diff --git a/apps/desktop/src/main/__tests__/settings-network-gateway-contract.test.ts b/apps/desktop/src/main/__tests__/settings-network-gateway-contract.test.ts index 9db9661aed..82edf89ccb 100644 --- a/apps/desktop/src/main/__tests__/settings-network-gateway-contract.test.ts +++ b/apps/desktop/src/main/__tests__/settings-network-gateway-contract.test.ts @@ -141,12 +141,12 @@ describe('Settings network and gateway persistence contract', () => { assert.match( networkBlock, - /const networkPageMountedRef = useRef\(false\);/, + /const networkPageMountedRef = useMountedRef\(\);/, 'Network proxy page must track mounted ownership for async save/test actions', ); assert.match( networkBlock, - /useEffect\(\(\) => \{[\s\S]*networkPageMountedRef\.current = true;[\s\S]*return \(\) => \{[\s\S]*networkPageMountedRef\.current = false;[\s\S]*proxySaveTicketRef\.current \+= 1;[\s\S]*proxyTestRunningRef\.current = false;/, + /useEffect\(\(\) => \{[\s\S]*return \(\) => \{[\s\S]*proxySaveTicketRef\.current \+= 1;[\s\S]*proxyTestRunningRef\.current = false;/, 'Network proxy cleanup must invalidate save tickets and release test ownership when Settings closes', ); assert.match( @@ -261,12 +261,12 @@ describe('Settings network and gateway persistence contract', () => { assert.match( gatewayBlock, - /const openGatewayMountedRef = useRef\(false\);/, + /const openGatewayMountedRef = useMountedRef\(\);/, 'Open Gateway page must track mounted ownership for async save/copy actions', ); assert.match( gatewayBlock, - /useEffect\(\(\) => \{[\s\S]*openGatewayMountedRef\.current = true;[\s\S]*return \(\) => \{[\s\S]*openGatewayMountedRef\.current = false;[\s\S]*gatewaySaveTicketRef\.current \+= 1;[\s\S]*copyingGatewayActionRef\.current = null;/, + /useEffect\(\(\) => \{[\s\S]*return \(\) => \{[\s\S]*gatewaySaveTicketRef\.current \+= 1;[\s\S]*copyingGatewayActionRef\.current = null;/, 'Open Gateway cleanup must invalidate save tickets and release copy ownership when Settings closes', ); assert.match( diff --git a/apps/desktop/src/main/__tests__/settings-theme-contract.test.ts b/apps/desktop/src/main/__tests__/settings-theme-contract.test.ts index d62d7ddd10..384c7b0532 100644 --- a/apps/desktop/src/main/__tests__/settings-theme-contract.test.ts +++ b/apps/desktop/src/main/__tests__/settings-theme-contract.test.ts @@ -52,12 +52,12 @@ describe('Settings theme page contract', () => { assert.match( themePage, - /const themePageMountedRef = useRef\(false\);[\s\S]*const themePersistTicketRef = useRef\(0\);/, + /const themePageMountedRef = useMountedRef\(\);[\s\S]*const themePersistTicketRef = useRef\(0\);/, 'Theme page must track mounted state and the newest persistence request', ); assert.match( themePage, - /useEffect\(\(\) => \{[\s\S]*themePageMountedRef\.current = true;[\s\S]*return \(\) => \{[\s\S]*themePageMountedRef\.current = false;[\s\S]*themePersistTicketRef\.current \+= 1;/, + /useEffect\(\(\) => \{[\s\S]*return \(\) => \{[\s\S]*themePersistTicketRef\.current \+= 1;/, 'Theme page cleanup must invalidate in-flight appearance persistence requests', ); assert.match( diff --git a/apps/desktop/src/main/__tests__/settings-usage-contract.test.ts b/apps/desktop/src/main/__tests__/settings-usage-contract.test.ts index c4c2498481..e35a4580d7 100644 --- a/apps/desktop/src/main/__tests__/settings-usage-contract.test.ts +++ b/apps/desktop/src/main/__tests__/settings-usage-contract.test.ts @@ -215,12 +215,12 @@ describe('Settings usage dashboard contract', () => { assert.match( usagePage, - /const usagePageMountedRef = useRef\(false\);/, + /const usagePageMountedRef = useMountedRef\(\);/, 'Usage settings page must track mounted ownership for async preference and refresh work', ); assert.match( usagePage, - /useEffect\(\(\) => \{[\s\S]*usagePageMountedRef\.current = true;[\s\S]*return \(\) => \{[\s\S]*usagePageMountedRef\.current = false;[\s\S]*usageSaveTicketRef\.current \+= 1;[\s\S]*usageRefreshRunningRef\.current = false;/, + /useEffect\(\(\) => \{[\s\S]*return \(\) => \{[\s\S]*usageSaveTicketRef\.current \+= 1;[\s\S]*usageRefreshRunningRef\.current = false;/, 'Usage settings cleanup must invalidate saves and release manual refresh ownership', ); assert.match( diff --git a/apps/desktop/src/main/__tests__/skills.test.ts b/apps/desktop/src/main/__tests__/skills.test.ts index c1ece93bdb..f25ca092d6 100644 --- a/apps/desktop/src/main/__tests__/skills.test.ts +++ b/apps/desktop/src/main/__tests__/skills.test.ts @@ -1159,11 +1159,11 @@ name: Writer assert.match(modulePagesSource, /export function SkillsPage[\s\S]*\(null\)/); - assert.match(skillsModuleMain, /const skillActionMountedRef = useRef\(true\)/); + assert.match(skillsModuleMain, /const skillActionMountedRef = useMountedRef\(\)/); assert.match(skillsModuleMain, /const pendingSkillActionRef = useRef\(null\)/); assert.match( skillsModuleMain, - /useEffect\(\(\) => \{\s*skillActionMountedRef\.current = true;[\s\S]*?return \(\) => \{\s*skillActionMountedRef\.current = false;\s*pendingSkillActionRef\.current = null;\s*\};\s*\}, \[\]\)/, + /useEffect\(\(\) => \{\s*return \(\) => \{\s*pendingSkillActionRef\.current = null;\s*\};\s*\}, \[\]\)/, 'Skills actions must release pending ownership when the module unmounts', ); assert.match(skillsModuleMain, /async function runSkillAction\(/); diff --git a/apps/desktop/src/main/__tests__/visible-copy-hygiene-contract.test.ts b/apps/desktop/src/main/__tests__/visible-copy-hygiene-contract.test.ts index a7baeca891..b28dec3ac6 100644 --- a/apps/desktop/src/main/__tests__/visible-copy-hygiene-contract.test.ts +++ b/apps/desktop/src/main/__tests__/visible-copy-hygiene-contract.test.ts @@ -369,11 +369,11 @@ describe('turn footer copy feedback contract', () => { const clipboardPath = resolve(process.cwd(), '..', '..', 'packages', 'ui', 'src', 'clipboard-feedback.ts'); const hookBlock = await readFile(clipboardPath, 'utf8'); - assert.match(hookBlock, /const copyMountedRef = useRef\(true\)/, 'Shared copy feedback must track mounted state.'); + assert.match(hookBlock, /const copyMountedRef = useMountedRef\(\)/, 'Shared copy feedback must track mounted state via the shared useMountedRef hook.'); assert.match( hookBlock, - /useEffect\(\(\) => \{\s*copyMountedRef\.current = true;\s*return \(\) => \{\s*copyMountedRef\.current = false;\s*clearResetTimer\(\);\s*\};\s*\}, \[\]\)/, - 'Shared copy feedback must restore mounted state during StrictMode effect replay, then cancel timers and mark itself unmounted during cleanup.', + /useEffect\(\(\) => \{\s*return \(\) => \{\s*clearResetTimer\(\);\s*\};\s*\}, \[\]\)/, + 'Shared copy feedback cleanup must cancel timers; the shared useMountedRef hook owns StrictMode-safe mount state.', ); assert.match( hookBlock, @@ -407,8 +407,8 @@ describe('turn footer copy feedback contract', () => { assert.match(footerBlock, /copyResetTimerRef/, 'Turn footer copy feedback should reset without leaking timers.'); assert.match( footerBlock, - /useEffect\(\(\) => \{\s*copyMountedRef\.current = true;\s*return \(\) => \{\s*copyMountedRef\.current = false;\s*clearCopyResetTimer\(\);\s*\};\s*\}, \[\]\)/, - 'Turn footer copy feedback must restore mounted state during StrictMode effect replay, then clear timers on cleanup.', + /useEffect\(\(\) => \{\s*return \(\) => \{\s*clearCopyResetTimer\(\);\s*\};\s*\}, \[\]\)/, + 'Turn footer copy feedback cleanup must clear timers; the shared useMountedRef hook owns StrictMode-safe mount state.', ); assert.match( footerBlock, diff --git a/apps/desktop/src/main/__tests__/voice-capture-smoke-contract.test.ts b/apps/desktop/src/main/__tests__/voice-capture-smoke-contract.test.ts index d2bf8f6484..ccb290b7a8 100644 --- a/apps/desktop/src/main/__tests__/voice-capture-smoke-contract.test.ts +++ b/apps/desktop/src/main/__tests__/voice-capture-smoke-contract.test.ts @@ -101,7 +101,7 @@ describe('voice capture smoke Settings contract', () => { assert.ok(voicePage, 'voice settings page source must be discoverable'); assert.match( voicePage!, - /const voicePageMountedRef = useRef\(false\);[\s\S]*const activeVoiceCaptureStreamRef = useRef\(null\);[\s\S]*voicePageMountedRef\.current = true;[\s\S]*return \(\) => \{[\s\S]*voicePageMountedRef\.current = false;[\s\S]*activeVoiceCaptureStreamRef\.current\?\.getTracks\(\)\.forEach\(\(track\) => track\.stop\(\)\);[\s\S]*activeVoiceCaptureStreamRef\.current = null;[\s\S]*captureSmokeBusyRef\.current = false;/, + /const voicePageMountedRef = useMountedRef\(\);[\s\S]*const activeVoiceCaptureStreamRef = useRef\(null\);[\s\S]*return \(\) => \{[\s\S]*activeVoiceCaptureStreamRef\.current\?\.getTracks\(\)\.forEach\(\(track\) => track\.stop\(\)\);[\s\S]*activeVoiceCaptureStreamRef\.current = null;[\s\S]*captureSmokeBusyRef\.current = false;/, 'voice capture smoke must track page ownership and release the pending owner when Settings closes', ); assert.match( diff --git a/apps/desktop/src/main/__tests__/web-search-boundary.test.ts b/apps/desktop/src/main/__tests__/web-search-boundary.test.ts index f2a07d4351..dae2211914 100644 --- a/apps/desktop/src/main/__tests__/web-search-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/web-search-boundary.test.ts @@ -232,12 +232,12 @@ describe('web-search renderer boundary (PR-WEB-SEARCH-TAVILY-0)', () => { assert.ok(page, 'Web search settings page block must exist'); assert.match( page![0], - /const webSearchMountedRef = useRef\(true\)/, + /const webSearchMountedRef = useMountedRef\(\)/, 'Web search Settings needs a mounted ref because test/query/save promises can settle after the section unmounts', ); assert.match( page![0], - /useEffect\(\(\) => \{[\s\S]*webSearchMountedRef\.current = true;[\s\S]*return \(\) => \{[\s\S]*webSearchMountedRef\.current = false;[\s\S]*pendingWebSearchEnabledRef\.current = false;[\s\S]*pendingCredentialActionRef\.current = null;[\s\S]*testingRef\.current = false;[\s\S]*liveQueryRunningRef\.current = false;[\s\S]*\};[\s\S]*\}, \[\]\)/, + /useEffect\(\(\) => \{[\s\S]*return \(\) => \{[\s\S]*pendingWebSearchEnabledRef\.current = false;[\s\S]*pendingCredentialActionRef\.current = null;[\s\S]*testingRef\.current = false;[\s\S]*liveQueryRunningRef\.current = false;[\s\S]*\};[\s\S]*\}, \[\]\)/, 'Unmount must mark the page inactive and release synchronous pending owners', ); assert.match( diff --git a/apps/desktop/src/renderer/FirstRunChecklist.tsx b/apps/desktop/src/renderer/FirstRunChecklist.tsx index cb47f5205d..8b8f1ce7e4 100644 --- a/apps/desktop/src/renderer/FirstRunChecklist.tsx +++ b/apps/desktop/src/renderer/FirstRunChecklist.tsx @@ -25,7 +25,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ArrowRight, BookOpen, CalendarDays, Check, Clock, FileText, Mic, RefreshCcw, Search, Sparkles, User } from '@maka/ui/icons'; import { generalizedErrorMessageChinese, type AppSettings, type PlanReminder, type SettingsSection } from '@maka/core'; -import { Alert, AlertAction, AlertDescription, Button, useToast } from '@maka/ui'; +import { Alert, AlertAction, AlertDescription, Button, useMountedRef, useToast } from '@maka/ui'; interface ChecklistItem { id: string; @@ -55,15 +55,13 @@ export function FirstRunChecklist(props: FirstRunChecklistProps) { const [workspaceInstructionCount, setWorkspaceInstructionCount] = useState(null); const [statusError, setStatusError] = useState(null); const [statusRefreshPending, setStatusRefreshPending] = useState(false); - const checklistMountedRef = useRef(true); + const checklistMountedRef = useMountedRef(); const failureToastShownRef = useRef(false); const statusRefreshPendingRef = useRef(false); const toast = useToast(); useEffect(() => { - checklistMountedRef.current = true; return () => { - checklistMountedRef.current = false; statusRefreshPendingRef.current = false; }; }, []); diff --git a/apps/desktop/src/renderer/OnboardingHero.tsx b/apps/desktop/src/renderer/OnboardingHero.tsx index 2699fc2137..c04eb92f52 100644 --- a/apps/desktop/src/renderer/OnboardingHero.tsx +++ b/apps/desktop/src/renderer/OnboardingHero.tsx @@ -535,7 +535,7 @@ function ReadyEmptyHero(props: { const [submitPending, setSubmitPending] = useState(false); const [pendingImportAction, setPendingImportAction] = useState(null); const inputRef = useRef(null); - const readyHeroMountedRef = useRef(true); + const readyHeroMountedRef = useMountedRef(); const submitPendingRef = useRef(false); const compositionActiveRef = useRef(false); const importActionOwnerRef = useRef | null>(null); @@ -546,9 +546,7 @@ function ReadyEmptyHero(props: { } useEffect(() => { - readyHeroMountedRef.current = true; return () => { - readyHeroMountedRef.current = false; submitPendingRef.current = false; importActionOwnerRef.current?.reset(); }; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index d0ae9a61bb..3c4889ab1e 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -7,12 +7,9 @@ import type { SettingsSection, ThemePalette, ThemePreference, - ThinkingLevel, } from '@maka/core'; -import { generalizedErrorMessageChinese, hasSettledInitialOnboarding, thinkingVariantsForModel } from '@maka/core'; +import { generalizedErrorMessageChinese, hasSettledInitialOnboarding } from '@maka/core'; import { - type ChatHeaderAlert, - type ChatModelChoice, AutomationsPage, ChatView, Composer, @@ -51,7 +48,6 @@ function BrowserPanelFallback() {
); } -import { deriveChatHeaderAlert } from './chat-header-alert'; import { useSessionGoal } from './use-session-goal'; import { deriveStaleSessionIds } from './stale-sessions'; import { deriveProjectGroups } from './session-project-grouping'; @@ -63,11 +59,7 @@ import { import { deriveAppShellTurnViewModel } from './app-shell-turn-view-model'; import { readScrollMotionBehavior } from './scroll-motion-policy'; import { deriveBranchBanner } from './branch-banner'; -import { pickCatalogDefaultChatModel } from './model-catalog-choices'; import { applyTheme, applyThemePalette, applyUiLocale } from './theme'; -import { hasInFlightToolActivity } from './session-event-health'; -import { MODEL_CONTINUING_DELAY_MS, MODEL_PROCESSING_DELAY_MS, deriveModelWait } from './model-wait-state'; -import { useDelayedFlag } from './use-delayed-flag'; import { safeLocalStorageSet } from './browser-storage'; import { filterSessions, readNavSelection } from './nav-selection'; import { @@ -80,7 +72,6 @@ import { import { modelSetupToastCopy, } from './model-connection-errors'; -import { buildChatModelChoices, chatModelChoiceLabel, normalizeActiveChatModel } from './chat-model-selection'; import { basenameFromPath } from './app-shell-copy'; import type { AppShellCommandListOptions } from './app-shell-command-actions'; import { AppShellTopbarActions, AppShellWorkspaceTopActions } from './app-shell-chrome-actions'; @@ -113,6 +104,8 @@ import { useKeyedPendingRegistry } from './use-pending-action-registry'; import { useAppShellComposerAttachments } from './use-app-shell-composer-attachments'; import { useAppShellSessionWorkspace } from './use-app-shell-session-workspace'; import { useShellConnections } from './use-shell-connections'; +import { useShellChatModel } from './use-shell-chat-model'; +import { useShellLiveTurn } from './use-shell-live-turn'; type ComposerImportOwner = { sessionId: string | undefined; @@ -226,9 +219,6 @@ export function AppShell({ // recent workspace history so the home view is populated before the async // `app:info` round-trip completes on mount. const persistedComposerDefaults = loadComposerDefaults(); - const [pendingNewChatModel, setPendingNewChatModel] = useState<{ llmConnectionSlug: string; model: string } | null>( - persistedComposerDefaults?.model ?? null, - ); const [helpOpen, closeHelp, openHelp] = useKeyboardHelp(); const [paletteOpen, openPalette, closePalette] = useCommandPalette(); // Search modal state. Sidebar `搜索` opens the real thread-search @@ -259,26 +249,6 @@ export function AppShell({ // kill-switch pill (visible indicator + one-click clear). const activeGoal = useSessionGoal(activeId); const activeLiveTurn = activeId ? liveTurnBySession[activeId] : undefined; - const activeShellRunUpdates = useMemo( - () => activeId ? Object.values(shellRunUpdatesBySession[activeId] ?? {}) : [], - [activeId, shellRunUpdatesBySession], - ); - const activeTextStep = [...(activeLiveTurn?.steps ?? [])].reverse().find((step) => step.text); - const activeThinkingStep = [...(activeLiveTurn?.steps ?? [])].reverse().find((step) => step.thinking); - const activeStreaming = activeTextStep?.text?.text ?? ''; - const activeStreamingComplete = activeTextStep?.text?.complete === true; - const activeStreamingLive = activeStreaming.length > 0 && !activeStreamingComplete; - const activeStreamingMessageId = activeStreamingComplete ? activeTextStep?.stepId : undefined; - const activeThinking = activeThinkingStep?.thinking?.text ?? ''; - // Set of session ids with a live streaming delta — drives the sidebar - // pulse indicator. Recomputed on every live projection change; cheap - // since the underlying map only has at most a handful of entries. - const streamingSessionIds = useMemo( - () => new Set(Object.entries(liveTurnBySession).flatMap(([id, projection]) => ( - projection.steps.some((step) => step.text?.text && !step.text.complete) ? [id] : [] - ))), - [liveTurnBySession], - ); // Set of session ids whose backend / connection is no longer usable — // drives the sidebar "已过期" pill (PR108g, paired with the PR108e chat // header banner). Derivation is pure (see `stale-sessions.ts`) so the @@ -303,8 +273,6 @@ export function AppShell({ ); const sessionProjectGroups = useMemo(() => deriveProjectGroups(visibleSessions), [visibleSessions]); const sessionListGroups = viewMode === 'project' ? sessionProjectGroups : sessionStatusGroups; - const liveTools = useMemo(() => activeLiveTurn?.steps.flatMap((step) => step.tools) ?? [], [activeLiveTurn]); - const hasInFlightLiveTools = useMemo(() => hasInFlightToolActivity(liveTools), [liveTools]); const activeSessionEventHealth = activeId ? sessionEventHealthBySession[activeId] : undefined; // PR-DAILY-REVIEW-MVP-0: bridge for the main Daily Review module. // Memoized so the panel's `useEffect` cleanup keys @@ -320,151 +288,66 @@ export function AppShell({ }); const activePermission = activePermissionFor(permissionBySession, activeId); const activeSession = sessions.find((session) => session.id === activeId); - // #646: the two turn-wait cues. `turnPhase` (armed at send, no lag; promoted to - // 'streamed' on the first content event) separates the connect-to-first-token - // wait from the later step-to-step lulls; the `status === 'running'` gate - // self-heals a backgrounded session whose terminal event was missed while - // inactive (its arm can't clear without the event). The rising-edge delays - // (useDelayedFlag) suppress a flash on fast turns / quick step hops. - const activeTurnPhase = activeLiveTurn?.terminal ? undefined : activeLiveTurn?.phase; - const turnInFlight = activeTurnPhase !== undefined; - const modelWaitKind = deriveModelWait({ - turnPhase: activeTurnPhase, - streamingText: activeStreaming, - thinkingText: activeThinking, - hasInFlightTools: hasInFlightLiveTools, + // Live-turn projection of the active session: streaming/thinking slices, the + // sidebar pulse set, the in-flight tool signal, and the #646 turn-wait cues + // all live in useShellLiveTurn (pure derivation of the live projection). + // `activeLiveTurn` itself stays here — a source-slice contract pins its + // declaration to app-shell.tsx — and is passed in. + const { + activeShellRunUpdates, + activeStreaming, + activeStreamingComplete, + activeStreamingLive, + activeStreamingMessageId, + activeThinking, + streamingSessionIds, + liveTools, + hasInFlightLiveTools, + turnInFlight, + sessionAwaitingModel, + showProcessingIndicator, + showContinuingIndicator, + } = useShellLiveTurn({ + activeId, + activeLiveTurn, + liveTurnBySession, + shellRunUpdatesBySession, + activeSession, }); - const sessionAwaitingModel = activeSession?.status === 'running'; - // The prominent "正在处理…" first-token indicator (turn head only). - const showProcessingIndicator = useDelayedFlag( - sessionAwaitingModel && modelWaitKind === 'processing', - MODEL_PROCESSING_DELAY_MS, - ); - // The calm "继续中…" hint for a mid-turn step-to-step lull (after content). - const showContinuingIndicator = useDelayedFlag( - sessionAwaitingModel && modelWaitKind === 'continuing', - MODEL_CONTINUING_DELAY_MS, - ); - const activeConnection = activeSession - ? connections.find((connection) => connection.slug === activeSession.llmConnectionSlug) - : undefined; - const defaultConnectionEntry = defaultConnection - ? connections.find((connection) => connection.slug === defaultConnection) - : undefined; - const chatModelChoices = useMemo( - () => buildChatModelChoices(connections), - [connections], - ); - // Home / empty-state composer: which model the next NEW chat starts with. - // Null = follow the default connection; a pick overrides it (sticky until - // changed) and is forwarded to sessions.create in `send()`. Renderer-only — - // it never mutates the persisted Settings · 模型 default. - const [pendingNewChatThinkingLevel, setPendingNewChatThinkingLevel] = useState(null); - // A pick only stays in effect while it is still an offered choice. If the user - // later disables/removes that connection or model, fall back to the default so - // the home chip never shows — nor sends — a model that no longer exists. - const validPendingNewChatModel = - pendingNewChatModel && - chatModelChoices.some( - (c) => c.connectionSlug === pendingNewChatModel.llmConnectionSlug && c.model === pendingNewChatModel.model, - ) - ? pendingNewChatModel - : null; - const catalogDefaultNewChatModel = defaultConnectionEntry - ? pickCatalogDefaultChatModel(defaultConnectionEntry) - : undefined; - const newChatModel = validPendingNewChatModel ?? catalogDefaultNewChatModel; - const activeConnectionLabel = activeSession?.backend === 'fake' - ? '本地模拟连接' - : activeConnection?.name ?? activeSession?.llmConnectionSlug; - const activeModel = activeSession?.backend === 'fake' - ? undefined - : normalizeActiveChatModel(activeSession, activeConnection, chatModelChoices); - const activeModelLabel = activeSession?.backend === 'fake' - ? undefined - : chatModelChoiceLabel(chatModelChoices, activeSession?.llmConnectionSlug, activeModel); - const activeThinkingLevels = useMemo( - () => (activeConnection && activeModel) ? thinkingVariantsForModel(activeConnection.providerType, activeModel) : [], - [activeConnection, activeModel], - ); - // Only surface a stored level when the current model still supports it; - // if the model changed (setModel clears it) or the catalog reconfigured so - // the level is no longer offered, the chip falls back to 默认 instead of - // advertising a level the runtime would silently drop. The runtime's - // `buildProviderOptions` is the wire-level guard; this keeps the UI honest. - const activeThinkingLevel = - activeSession?.thinkingLevel && activeThinkingLevels.includes(activeSession.thinkingLevel) - ? activeSession.thinkingLevel - : undefined; - const newChatThinkingLevels = useMemo( - () => { - if (!newChatModel) return []; - const c = connections.find((entry) => entry.slug === newChatModel.llmConnectionSlug); - return c ? thinkingVariantsForModel(c.providerType, newChatModel.model) : []; - }, - [newChatModel, connections], - ); - const newChatThinkingLevel = pendingNewChatThinkingLevel && newChatThinkingLevels.includes(pendingNewChatThinkingLevel) - ? pendingNewChatThinkingLevel - : undefined; - const newChatModelLabel = chatModelChoiceLabel(chatModelChoices, newChatModel?.llmConnectionSlug, newChatModel?.model); - // Surface a credential-lifecycle alert directly in the chat header when // the active session's connection is in `needs_reauth` / `error` or has // been deleted entirely. We skip the async hasSecret fetch here — the // chat header is a hint surface; AccountSettingsPage remains the - // authoritative detailed view. - // Cheap renderer-side "is the default connection plausibly ready" check — - // used to decide whether a stale session can be silent-rebound on send - // (xuan's send-path rebind requires a ready default) or whether the user - // has to fix Settings first. We can't verify `hasSecret` synchronously - // here without an extra IPC round-trip; backend remains authoritative if - // the secret is missing — it will surface `missing_api_key` reason at - // send time. For banner copy purposes, "default exists + enabled" is - // enough. - const defaultConnectionReady = useMemo(() => { - if (!defaultConnection) return false; - const entry = connections.find((connection) => connection.slug === defaultConnection); - return entry?.enabled === true; - }, [defaultConnection, connections]); - - // Banner derivation is a pure function (see `chat-header-alert.ts`); we - // wrap the returned `onClickTarget` here with the Settings-jump action. - const chatConnectionAlert = useMemo(() => { - const derived = deriveChatHeaderAlert({ - backend: activeSession?.backend, - hasActiveConnection: Boolean(activeConnection), - defaultConnectionReady, - lastTestStatus: activeConnection?.lastTestStatus, - }); - if (!derived) return undefined; - const target = derived.onClickTarget; - return { - tone: derived.tone, - label: derived.label, - ...(derived.tooltip ? { tooltip: derived.tooltip } : {}), - onClick: () => openSettingsSection(target), - }; - // openSettingsSection is stable enough for our purposes — main.tsx - // doesn't depend on it changing, and including it would force the - // effect to re-create on every render due to its function identity. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - activeSession?.id, - activeSession?.backend, - activeConnection?.slug, - activeConnection?.lastTestStatus, - defaultConnectionReady, - ]); - - const chatEventStreamAlert = useMemo(() => { - if (activeSessionEventHealth?.status !== 'stale') return undefined; - return { - tone: 'warning', - label: '事件流恢复中', - tooltip: '当前对话的实时事件需要刷新,Maka 正在从本地会话记录恢复。', - }; - }, [activeSessionEventHealth?.status]); + // authoritative detailed view. The model/thinking selection + both + // chat-header alerts live in useShellChatModel (pure derivation of the + // connection list + active session); openSettingsSection is injected so + // the connection alert can wrap the derived click target. + const { + chatModelChoices, + activeConnection, + activeConnectionLabel, + activeModel, + activeModelLabel, + activeThinkingLevels, + activeThinkingLevel, + newChatModel, + newChatModelLabel, + newChatThinkingLevels, + newChatThinkingLevel, + validPendingNewChatModel, + setPendingNewChatModel, + pendingNewChatThinkingLevel, + setPendingNewChatThinkingLevel, + chatConnectionAlert, + chatEventStreamAlert, + } = useShellChatModel({ + connections, + defaultConnection, + activeSession, + activeSessionEventHealth, + persistedComposerDefaults, + openSettingsSection, + }); // PR109d-b: turn footer actions per turn. Derived from the // materialized turn list (status + lineage descendants) + pending diff --git a/apps/desktop/src/renderer/artifact-pane.tsx b/apps/desktop/src/renderer/artifact-pane.tsx index 4ecd56046f..b8dc983cd4 100644 --- a/apps/desktop/src/renderer/artifact-pane.tsx +++ b/apps/desktop/src/renderer/artifact-pane.tsx @@ -73,6 +73,7 @@ import { TooltipContent, TooltipTrigger, formatBytes, + useMountedRef, useToast, } from '@maka/ui'; import { ArtifactPreview } from './artifact-preview'; @@ -94,7 +95,7 @@ export function ArtifactPane(props: { sessionId: string | undefined }) { const [pendingArtifactListRetry, setPendingArtifactListRetry] = useState(false); const [pendingArtifactAction, setPendingArtifactAction] = useState(null); const artifactListRequestSeqRef = useRef(0); - const artifactPaneMountedRef = useRef(true); + const artifactPaneMountedRef = useMountedRef(); const artifactPaneSessionIdRef = useRef(sessionId); const recordsSessionIdRef = useRef(undefined); const pendingArtifactListRetryRef = useRef(false); @@ -105,9 +106,7 @@ export function ArtifactPane(props: { sessionId: string | undefined }) { // ---- live data --------------------------------------------------------- useEffect(() => { - artifactPaneMountedRef.current = true; return () => { - artifactPaneMountedRef.current = false; artifactListRequestSeqRef.current += 1; pendingArtifactListRetryRef.current = false; pendingArtifactActionRef.current = null; diff --git a/apps/desktop/src/renderer/browser-panel.tsx b/apps/desktop/src/renderer/browser-panel.tsx index 698f7c5533..aaf34abb05 100644 --- a/apps/desktop/src/renderer/browser-panel.tsx +++ b/apps/desktop/src/renderer/browser-panel.tsx @@ -27,6 +27,7 @@ import { Tooltip, TooltipContent, TooltipTrigger, + useMountedRef, useToast, } from '@maka/ui'; @@ -60,18 +61,11 @@ export function BrowserPanel(props: { sessionId: string; hidden: boolean }) { // did-navigate state push. const [address, setAddress] = useState(''); const editingRef = useRef(false); - const browserPanelMountedRef = useRef(false); + const browserPanelMountedRef = useMountedRef(); const browserPanelSessionIdRef = useRef(sessionId); browserPanelSessionIdRef.current = sessionId; - useEffect(() => { - browserPanelMountedRef.current = true; - return () => { - browserPanelMountedRef.current = false; - }; - }, []); - const isBrowserPanelSessionCurrent = useCallback((ownerSessionId: string): boolean => { return browserPanelMountedRef.current && browserPanelSessionIdRef.current === ownerSessionId; }, []); diff --git a/apps/desktop/src/renderer/settings/ProvidersPanel.tsx b/apps/desktop/src/renderer/settings/ProvidersPanel.tsx index 1508e004b5..1584fcbccf 100644 --- a/apps/desktop/src/renderer/settings/ProvidersPanel.tsx +++ b/apps/desktop/src/renderer/settings/ProvidersPanel.tsx @@ -14,6 +14,7 @@ import { PrimitiveTabs, PrimitiveTabsList, PrimitiveTabsTrigger, PrimitiveTabsPanel, PrimitiveAccordion, PrimitiveAccordionItem, PrimitiveAccordionHeader, PrimitiveAccordionTrigger, PrimitiveAccordionPanel, Item, ItemContent, ItemTitle, ItemActions, + useMountedRef, useToast, } from '@maka/ui'; import { chipStatusText, rollupForGroup } from './provider-connection-status'; @@ -60,7 +61,7 @@ export function ProvidersPanel({ bridge, initialPage = 'connections' }: { const [catalogQuery, setCatalogQuery] = useState(''); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); - const providersPanelMountedRef = useRef(false); + const providersPanelMountedRef = useMountedRef(); const providersReloadTicketRef = useRef(0); const providerPageLifecycleRef = useRef(0); const providersPanelRef = useRef(null); @@ -108,13 +109,11 @@ export function ProvidersPanel({ bridge, initialPage = 'connections' }: { } useEffect(() => { - providersPanelMountedRef.current = true; void reload(); const unsubscribe = bridge.subscribeEvents?.(() => { void reload(); }); return () => { - providersPanelMountedRef.current = false; providersReloadTicketRef.current += 1; providerPageLifecycleRef.current += 1; unsubscribe?.(); diff --git a/apps/desktop/src/renderer/settings/about-settings-page.tsx b/apps/desktop/src/renderer/settings/about-settings-page.tsx index 9a7a2e5d2a..06ed676a0f 100644 --- a/apps/desktop/src/renderer/settings/about-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/about-settings-page.tsx @@ -1,6 +1,6 @@ import { useEffect, useId, useRef, useState } from 'react'; import { Sparkles } from '@maka/ui/icons'; -import { Button, PageHeader, useToast } from '@maka/ui'; +import { Button, PageHeader, useMountedRef, useToast } from '@maka/ui'; import { SettingsRows, SettingRow } from './settings-rows'; import { settingsActionErrorMessage } from './settings-error-copy'; import { SettingsSkeletonStack } from './settings-skeleton'; @@ -18,13 +18,12 @@ export function AboutSettingsPage() { const [infoError, setInfoError] = useState(null); const [copyingEnvSummary, setCopyingEnvSummary] = useState(false); const copyingEnvSummaryRef = useRef(false); - const aboutPageMountedRef = useRef(false); + const aboutPageMountedRef = useMountedRef(); const toast = useToast(); const envSummaryHelpId = useId(); useEffect(() => { let cancelled = false; - aboutPageMountedRef.current = true; window.maka.app .info() .then((next) => { @@ -41,7 +40,6 @@ export function AboutSettingsPage() { }); return () => { cancelled = true; - aboutPageMountedRef.current = false; copyingEnvSummaryRef.current = false; }; }, [toast]); diff --git a/apps/desktop/src/renderer/settings/account-settings-page.tsx b/apps/desktop/src/renderer/settings/account-settings-page.tsx index ab3a053258..b0f3add22c 100644 --- a/apps/desktop/src/renderer/settings/account-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/account-settings-page.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import type { ConnectionTestResult, LlmConnection } from '@maka/core'; import { deriveProviderAuthContractFromConnection, generalizedErrorMessageChinese } from '@maka/core'; import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; -import { Button, Chip, RelativeTime, useToast } from '@maka/ui'; +import { Button, Chip, RelativeTime, useMountedRef, useToast } from '@maka/ui'; import { deriveAccountAuthActions, presentAccountAuthState, @@ -69,13 +69,11 @@ export function AccountSettingsPage(props: { const [secretProbeError, setSecretProbeError] = useState(null); const [testingSlug, setTestingSlug] = useState(null); const testingSlugRef = useRef(null); - const accountPageMountedRef = useRef(false); + const accountPageMountedRef = useMountedRef(); const toast = useToast(); useEffect(() => { - accountPageMountedRef.current = true; return () => { - accountPageMountedRef.current = false; testingSlugRef.current = null; }; }, []); diff --git a/apps/desktop/src/renderer/settings/appearance-settings-page.tsx b/apps/desktop/src/renderer/settings/appearance-settings-page.tsx index 290d5d97c6..511023ba33 100644 --- a/apps/desktop/src/renderer/settings/appearance-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/appearance-settings-page.tsx @@ -7,7 +7,7 @@ import type { ThemePreference, UpdateAppSettingsResult, } from '@maka/core'; -import { ChoiceCard, ChoiceCardGroup, Input, SettingsSegmented as Segmented, Textarea, useToast } from '@maka/ui'; +import { ChoiceCard, ChoiceCardGroup, Input, SettingsSegmented as Segmented, Textarea, useMountedRef, useToast } from '@maka/ui'; import { applyUiLocale, type UiLocalePreference } from '../theme'; import { settingsActionErrorMessage } from './settings-error-copy'; @@ -64,7 +64,7 @@ export function PersonalizationSettingsPage(props: { const [assistantTone, setAssistantTone] = useState(value.assistantTone); const [uiLocale, setUiLocale] = useState(value.uiLocale); const toast = useToast(); - const personalizationMountedRef = useRef(false); + const personalizationMountedRef = useMountedRef(); // Last-write-wins persist queue, mirrored on NetworkProxySection below: // a monotonic ticket disambiguates overlapping in-flight saves so a stale // response can't clobber a newer one, and a pending-count keeps the sync @@ -75,9 +75,7 @@ export function PersonalizationSettingsPage(props: { const toneDebounceRef = useRef | null>(null); useEffect(() => { - personalizationMountedRef.current = true; return () => { - personalizationMountedRef.current = false; // Invalidate any in-flight save's late UI write, and drop the pending // debounced flush so it can't fire after the panel closes. persistTicketRef.current += 1; @@ -330,13 +328,11 @@ function ThemeSettingsPage(props: { onThemePaletteChange(palette: ThemePalette): void; }) { const toast = useToast(); - const themePageMountedRef = useRef(false); + const themePageMountedRef = useMountedRef(); const themePersistTicketRef = useRef(0); useEffect(() => { - themePageMountedRef.current = true; return () => { - themePageMountedRef.current = false; themePersistTicketRef.current += 1; }; }, []); diff --git a/apps/desktop/src/renderer/settings/bot-chat-settings-page.tsx b/apps/desktop/src/renderer/settings/bot-chat-settings-page.tsx index 83474ea086..5e8d3cd6ac 100644 --- a/apps/desktop/src/renderer/settings/bot-chat-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/bot-chat-settings-page.tsx @@ -33,6 +33,7 @@ import { SettingsSelect, SettingsSwitch as Switch, Textarea, + useMountedRef, useToast, } from '@maka/ui'; import { PasswordInput } from './password-input'; @@ -176,16 +177,14 @@ export function BotChatSettingsPage(props: { const toast = useToast(); const selectedStatus = statuses?.[selected]; const pendingBotActionRef = useRef(null); - const botPageMountedRef = useRef(false); + const botPageMountedRef = useMountedRef(); const botActionBusy = pendingBotAction !== null; const selectedBotActionPending = pendingBotAction?.provider === selected ? pendingBotAction.action : null; const testing = selectedBotActionPending === 'test' || selectedBotActionPending === 'connect'; const restarting = selectedBotActionPending === 'restart'; useEffect(() => { - botPageMountedRef.current = true; return () => { - botPageMountedRef.current = false; pendingBotActionRef.current = null; }; }, []); diff --git a/apps/desktop/src/renderer/settings/bot-wechat-login.tsx b/apps/desktop/src/renderer/settings/bot-wechat-login.tsx index 18d6c2e5ab..7b963d039e 100644 --- a/apps/desktop/src/renderer/settings/bot-wechat-login.tsx +++ b/apps/desktop/src/renderer/settings/bot-wechat-login.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import type { BotChannelSettings } from '@maka/core'; import type { WechatBridgeQrCodeResult } from '@maka/runtime'; -import { Button, DialogContent, DialogHeader, DialogRoot, Input } from '@maka/ui'; +import { Button, DialogContent, DialogHeader, DialogRoot, Input, useMountedRef } from '@maka/ui'; import { PasswordInput } from './password-input'; import { settingsActionErrorMessage } from './settings-error-copy'; @@ -95,7 +95,7 @@ export function WeChatScanLoginModal(props: { const fetchingQrRef = useRef(false); const scanLoginPollingRef = useRef(false); const scanLoginConfirmingRef = useRef(false); - const scanLoginMountedRef = useRef(false); + const scanLoginMountedRef = useMountedRef(); const scanLoginFetchTicketRef = useRef(0); async function fetchQr() { @@ -128,10 +128,8 @@ export function WeChatScanLoginModal(props: { } useEffect(() => { - scanLoginMountedRef.current = true; void fetchQr(); return () => { - scanLoginMountedRef.current = false; scanLoginFetchTicketRef.current += 1; fetchingQrRef.current = false; scanLoginPollingRef.current = false; diff --git a/apps/desktop/src/renderer/settings/data-settings-page.tsx b/apps/desktop/src/renderer/settings/data-settings-page.tsx index d66857f3e7..f8133b7b2c 100644 --- a/apps/desktop/src/renderer/settings/data-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/data-settings-page.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import type { ConfigCategory } from '@maka/storage'; -import { Button, SettingsSelect, SettingsSwitch as Switch, clearGlobalInputHistory, useToast } from '@maka/ui'; +import { Button, SettingsSelect, SettingsSwitch as Switch, clearGlobalInputHistory, useMountedRef, useToast } from '@maka/ui'; import { openPathFailureCopy, openPathActionLabel } from '../open-path'; import { SettingsRows, SettingRow } from './settings-rows'; import { settingsActionErrorMessage } from './settings-error-copy'; @@ -45,7 +45,7 @@ export function DataSettingsPage() { const [infoError, setInfoError] = useState(null); const [pendingDataAction, setPendingDataAction] = useState(null); const pendingDataActionRef = useRef(null); - const dataPageMountedRef = useRef(false); + const dataPageMountedRef = useMountedRef(); const toast = useToast(); const [selectedCategories, setSelectedCategories] = useState>( () => new Set(['connections', 'settings']), @@ -55,7 +55,6 @@ export function DataSettingsPage() { useEffect(() => { let cancelled = false; - dataPageMountedRef.current = true; void window.maka.app.info().then((next) => { if (!cancelled) { setInfo(next); @@ -70,7 +69,6 @@ export function DataSettingsPage() { }); return () => { cancelled = true; - dataPageMountedRef.current = false; pendingDataActionRef.current = null; }; }, [toast]); diff --git a/apps/desktop/src/renderer/settings/general-settings-page.tsx b/apps/desktop/src/renderer/settings/general-settings-page.tsx index 876a346865..d5a5e4122f 100644 --- a/apps/desktop/src/renderer/settings/general-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/general-settings-page.tsx @@ -251,13 +251,11 @@ function NetworkProxySection(props: { const proxyPendingSaveCountRef = useRef(0); const proxySaveTicketRef = useRef(0); const proxyTestRunningRef = useRef(false); - const networkPageMountedRef = useRef(false); + const networkPageMountedRef = useMountedRef(); const toast = useToast(); useEffect(() => { - networkPageMountedRef.current = true; return () => { - networkPageMountedRef.current = false; proxySaveTicketRef.current += 1; proxyTestRunningRef.current = false; }; diff --git a/apps/desktop/src/renderer/settings/open-gateway-settings-page.tsx b/apps/desktop/src/renderer/settings/open-gateway-settings-page.tsx index cb41d4e770..6955792874 100644 --- a/apps/desktop/src/renderer/settings/open-gateway-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/open-gateway-settings-page.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import type { AppSettings, OpenGatewayRuntimeStatus, UpdateAppSettingsResult } from '@maka/core'; -import { Button, Input, NumberField, NumberFieldInput, SettingsSelect, SettingsSwitch as Switch, Textarea, useToast } from '@maka/ui'; +import { Button, Input, NumberField, NumberFieldInput, SettingsSelect, SettingsSwitch as Switch, Textarea, useMountedRef, useToast } from '@maka/ui'; import { PasswordInput } from './password-input'; import { MetricCard } from './settings-metric-card'; import { SettingsRows, SettingRow } from './settings-rows'; @@ -23,13 +23,11 @@ export function OpenGatewaySettingsPage(props: { const gatewayPendingSaveCountRef = useRef(0); const gatewaySaveTicketRef = useRef(0); const copyingGatewayActionRef = useRef(null); - const openGatewayMountedRef = useRef(false); + const openGatewayMountedRef = useMountedRef(); const toast = useToast(); useEffect(() => { - openGatewayMountedRef.current = true; return () => { - openGatewayMountedRef.current = false; gatewaySaveTicketRef.current += 1; copyingGatewayActionRef.current = null; }; diff --git a/apps/desktop/src/renderer/settings/permission-center-page.tsx b/apps/desktop/src/renderer/settings/permission-center-page.tsx index 7cdb14e45c..df96fd03fc 100644 --- a/apps/desktop/src/renderer/settings/permission-center-page.tsx +++ b/apps/desktop/src/renderer/settings/permission-center-page.tsx @@ -345,15 +345,13 @@ function CapabilityRow(props: { capability: CapabilitySnapshot }) { const toast = useToast(); const [copyingOfficeCliInstall, setCopyingOfficeCliInstall] = useState(false); const copyingOfficeCliInstallRef = useRef(false); - const capabilityRowMountedRef = useRef(false); + const capabilityRowMountedRef = useMountedRef(); const readinessCopy = CAPABILITY_READINESS_COPY[capability.readiness]; const showOfficeCliInstallActions = capability.id === 'office_documents' && capability.runtimeProbe.state !== 'healthy'; useEffect(() => { - capabilityRowMountedRef.current = true; return () => { - capabilityRowMountedRef.current = false; copyingOfficeCliInstallRef.current = false; }; }, []); diff --git a/apps/desktop/src/renderer/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx index 11a53bde37..27c5817b3a 100644 --- a/apps/desktop/src/renderer/settings/provider-add-form.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { PROVIDER_DEFAULTS, validateSlug, type ProviderType } from '@maka/core'; -import { Button, Chip, Input } from '@maka/ui'; +import { Button, Chip, Input, useMountedRef } from '@maka/ui'; import { buildCatalogRecommendedDefaultModel } from '../model-catalog-choices'; import { providerDisplay } from './provider-display'; import { @@ -28,16 +28,14 @@ export function AddProviderForm(props: { const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const busyRef = useRef(false); - const addProviderMountedRef = useRef(false); + const addProviderMountedRef = useMountedRef(); const requiresBaseUrl = !defaults.baseUrl; const isExperimental = defaults.status === 'phase3-experimental'; const isWiredOAuth = isWiredOAuthProvider(props.providerType); useEffect(() => { - addProviderMountedRef.current = true; return () => { - addProviderMountedRef.current = false; busyRef.current = false; }; }, []); diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx index dae5598ee9..689d5b4a47 100644 --- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx +++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx @@ -16,7 +16,7 @@ import { type ProviderType, } from '@maka/core'; import { formatRelativeTimestamp } from '@maka/core'; -import { Button, Chip, FieldDescription, FieldRoot, Input, Label, useToast } from '@maka/ui'; +import { Button, Chip, FieldDescription, FieldRoot, Input, Label, useMountedRef, useToast } from '@maka/ui'; import { PasswordInput } from './password-input'; import { buildCatalogModelChoices } from '../model-catalog-choices'; import { providerDisplay } from './provider-display'; @@ -114,7 +114,7 @@ export function ConnectionDetail(props: { const fetchingModelsRef = useRef(false); const settingDefaultRef = useRef(false); const deletingRef = useRef(false); - const connectionDetailMountedRef = useRef(false); + const connectionDetailMountedRef = useMountedRef(); const connectionDetailLifecycleRef = useRef(0); const toast = useToast(); const needsApiKey = defaults.authKind === 'api_key'; @@ -136,10 +136,8 @@ export function ConnectionDetail(props: { const detailActionBusy = busy || testing || fetchingModels || settingDefault || deleting; useEffect(() => { - connectionDetailMountedRef.current = true; connectionDetailLifecycleRef.current += 1; return () => { - connectionDetailMountedRef.current = false; connectionDetailLifecycleRef.current += 1; busyRef.current = false; testingRef.current = false; diff --git a/apps/desktop/src/renderer/settings/provider-oauth-section.tsx b/apps/desktop/src/renderer/settings/provider-oauth-section.tsx index ff3dd9c102..cc2c55e4a0 100644 --- a/apps/desktop/src/renderer/settings/provider-oauth-section.tsx +++ b/apps/desktop/src/renderer/settings/provider-oauth-section.tsx @@ -15,6 +15,7 @@ import { ItemTitle, RelativeTime, Textarea, + useMountedRef, useToast, } from '@maka/ui'; import { type StatusTone } from './settings-status-badge'; @@ -79,7 +80,7 @@ const MODEL_OAUTH_CARDS: ReadonlyArray = [ export function ModelOAuthSection(props: { onConnectionsChanged(): Promise }) { const [openModal, setOpenModal] = useState(null); const toast = useToast(); - const modelOAuthMountedRef = useRef(false); + const modelOAuthMountedRef = useMountedRef(); const modelOAuthRefreshTicketRef = useRef(0); // PR-OAUTH-CARD-LIVE-STATE-0 (WAWQAQ msg d79fd115 follow-up): // before this lift the 3 button cards stayed at the static @@ -144,10 +145,8 @@ export function ModelOAuthSection(props: { onConnectionsChanged(): Promise } useEffect(() => { - modelOAuthMountedRef.current = true; void refreshAllCards(); return () => { - modelOAuthMountedRef.current = false; modelOAuthRefreshTicketRef.current += 1; }; }, []); @@ -430,11 +429,9 @@ function ClaudeSubscriptionCard() { // would `setState` on an unmounted component (loud warning in dev, // masks real bugs in prod). Mirror the `mountedRef` pattern other // settings sub-cards in this file use. - const claudeCardMountedRef = useRef(true); + const claudeCardMountedRef = useMountedRef(); useEffect(() => { - claudeCardMountedRef.current = true; return () => { - claudeCardMountedRef.current = false; const pendingAuthRequestId = claudeAuthRequestIdRef.current; claudeAuthRequestIdRef.current = null; if (pendingAuthRequestId) void window.maka.claudeSubscription.cancelAuthorization(pendingAuthRequestId); diff --git a/apps/desktop/src/renderer/settings/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx index 3830e32aae..71cd5dbb66 100644 --- a/apps/desktop/src/renderer/settings/settings-surface.tsx +++ b/apps/desktop/src/renderer/settings/settings-surface.tsx @@ -11,7 +11,7 @@ import type { UsageStats, } from '@maka/core'; import { createDefaultSettings } from '@maka/core/settings'; -import { Button, OverlayScrollArea, useToast } from '@maka/ui'; +import { Button, OverlayScrollArea, useMountedRef, useToast } from '@maka/ui'; import { ProvidersPanel } from './ProvidersPanel'; import { safeLocalStorageSet } from '../browser-storage'; import { AccountSettingsPage } from './account-settings-page'; @@ -104,7 +104,7 @@ export function SettingsSurface(props: { const [settings, setSettings] = useState(() => createDefaultSettings()); const [usageStats, setUsageStats] = useState(null); const [loading, setLoading] = useState(true); - const settingsModalMountedRef = useRef(false); + const settingsModalMountedRef = useMountedRef(); const settingsReloadTicketRef = useRef(0); const settingsUpdateTicketRef = useRef(0); const usageReloadTicketRef = useRef(0); @@ -117,9 +117,7 @@ export function SettingsSurface(props: { }, [loading, providerCatalogRequested, section]); useEffect(() => { - settingsModalMountedRef.current = true; return () => { - settingsModalMountedRef.current = false; settingsReloadTicketRef.current += 1; settingsUpdateTicketRef.current += 1; usageReloadTicketRef.current += 1; diff --git a/apps/desktop/src/renderer/settings/usage-settings-page.tsx b/apps/desktop/src/renderer/settings/usage-settings-page.tsx index 2cf6484ffd..1383755834 100644 --- a/apps/desktop/src/renderer/settings/usage-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/usage-settings-page.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import type { AppSettings, UpdateAppSettingsResult, UsageRange, UsageStats } from '@maka/core'; -import { Button, Input, SettingsSegmented as Segmented, SettingsSelect, SettingsSwitch as Switch, useToast } from '@maka/ui'; +import { Button, Input, SettingsSegmented as Segmented, SettingsSelect, SettingsSwitch as Switch, useMountedRef, useToast } from '@maka/ui'; import { RefreshCcw } from '@maka/ui/icons'; import { MetricCard } from './settings-metric-card'; import { settingsActionErrorMessage } from './settings-error-copy'; @@ -20,7 +20,7 @@ export function UsageSettingsPage(props: { const usagePendingSaveCountRef = useRef(0); const usageSaveTicketRef = useRef(0); const usageRefreshRunningRef = useRef(false); - const usagePageMountedRef = useRef(false); + const usagePageMountedRef = useMountedRef(); const stats = props.stats; const toast = useToast(); @@ -37,9 +37,7 @@ export function UsageSettingsPage(props: { }, [persistedUsage]); useEffect(() => { - usagePageMountedRef.current = true; return () => { - usagePageMountedRef.current = false; usageSaveTicketRef.current += 1; usageRefreshRunningRef.current = false; }; diff --git a/apps/desktop/src/renderer/settings/use-oauth-login-flow.ts b/apps/desktop/src/renderer/settings/use-oauth-login-flow.ts index fa6a0c7952..8b7c0d2d1f 100644 --- a/apps/desktop/src/renderer/settings/use-oauth-login-flow.ts +++ b/apps/desktop/src/renderer/settings/use-oauth-login-flow.ts @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { generalizedErrorMessageChinese, redactSecrets } from '@maka/core'; -import { useToast } from '@maka/ui'; +import { useMountedRef, useToast } from '@maka/ui'; import { createOneShotActionGuard, teardownPendingAuthorization } from './oauth-login-flow-guard'; export { createOneShotActionGuard, teardownPendingAuthorization } from './oauth-login-flow-guard'; @@ -81,7 +81,7 @@ export function useOAuthLoginFlow(params: { const [errorMessage, setErrorMessage] = useState(null); const pendingGuard = useRef(createOneShotActionGuard()).current; const authRequestIdRef = useRef(null); - const oauthLoginFlowMountedRef = useRef(false); + const oauthLoginFlowMountedRef = useMountedRef(); async function refresh(): Promise { try { @@ -99,10 +99,8 @@ export function useOAuthLoginFlow(params: { } useEffect(() => { - oauthLoginFlowMountedRef.current = true; void refresh(); return () => { - oauthLoginFlowMountedRef.current = false; pendingGuard.finish(); teardownPendingAuthorization(authRequestIdRef, (id) => void bridge.cancelAuthorization(id)); }; diff --git a/apps/desktop/src/renderer/settings/voice-settings-page.tsx b/apps/desktop/src/renderer/settings/voice-settings-page.tsx index dafa60fb74..517bb2eb6c 100644 --- a/apps/desktop/src/renderer/settings/voice-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/voice-settings-page.tsx @@ -2,7 +2,7 @@ import { useEffect, useId, useRef, useState } from 'react'; import { Volume2 } from '@maka/ui/icons'; import type { VoicePermissionStatus } from '@maka/core'; import { defaultVoiceCaptureCaps, validateVoiceCaptureRequest } from '@maka/core'; -import { Button, PageHeader, formatBytes, useToast } from '@maka/ui'; +import { Button, PageHeader, formatBytes, useMountedRef, useToast } from '@maka/ui'; type VoiceSmokeState = | { status: 'idle'; message: string } @@ -19,16 +19,14 @@ export function VoiceModelsSettingsPage() { }); const [isBusy, setIsBusy] = useState(false); const captureSmokeBusyRef = useRef(false); - const voicePageMountedRef = useRef(false); + const voicePageMountedRef = useMountedRef(); const activeVoiceCaptureStreamRef = useRef(null); const toast = useToast(); const caps = defaultVoiceCaptureCaps(); const smokeStatusId = useId(); useEffect(() => { - voicePageMountedRef.current = true; return () => { - voicePageMountedRef.current = false; activeVoiceCaptureStreamRef.current?.getTracks().forEach((track) => track.stop()); activeVoiceCaptureStreamRef.current = null; captureSmokeBusyRef.current = false; diff --git a/apps/desktop/src/renderer/settings/web-search-settings-page.tsx b/apps/desktop/src/renderer/settings/web-search-settings-page.tsx index a6f542471a..7800c1c07c 100644 --- a/apps/desktop/src/renderer/settings/web-search-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/web-search-settings-page.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import type { AppSettings, UpdateAppSettingsResult, WebSearchCredentialStatus } from '@maka/core'; import { normalizeSearchUrl, webSearchCredentialStatusFromResponse } from '@maka/core'; -import { Button, Chip, Input, RelativeTime, SettingsSwitch as Switch, redactSecrets, useToast } from '@maka/ui'; +import { Button, Chip, Input, RelativeTime, SettingsSwitch as Switch, redactSecrets, useMountedRef, useToast } from '@maka/ui'; import { PasswordInput } from './password-input'; import { settingsActionErrorMessage } from './settings-error-copy'; import { SettingsRows } from './settings-rows'; @@ -36,7 +36,7 @@ export function WebSearchSettingsPage(props: { const [liveQueryRunning, setLiveQueryRunning] = useState(false); const [liveQueryResults, setLiveQueryResults] = useState(null); const [liveQueryError, setLiveQueryError] = useState(null); - const webSearchMountedRef = useRef(true); + const webSearchMountedRef = useMountedRef(); const pendingWebSearchEnabledRef = useRef(false); const pendingCredentialActionRef = useRef<'save' | 'clear' | null>(null); const testingRef = useRef(false); @@ -45,9 +45,7 @@ export function WebSearchSettingsPage(props: { const toast = useToast(); useEffect(() => { - webSearchMountedRef.current = true; return () => { - webSearchMountedRef.current = false; pendingWebSearchEnabledRef.current = false; pendingCredentialActionRef.current = null; testingRef.current = false; diff --git a/apps/desktop/src/renderer/use-shell-chat-model.ts b/apps/desktop/src/renderer/use-shell-chat-model.ts new file mode 100644 index 0000000000..686619d340 --- /dev/null +++ b/apps/desktop/src/renderer/use-shell-chat-model.ts @@ -0,0 +1,195 @@ +import { useMemo, useState } from 'react'; +import type { LlmConnection, SessionEventStreamSnapshot, SessionSummary, SettingsSection, ThinkingLevel } from '@maka/core'; +import { thinkingVariantsForModel } from '@maka/core'; +import type { ChatHeaderAlert, ChatModelChoice } from '@maka/ui'; +import { deriveChatHeaderAlert } from './chat-header-alert'; +import { pickCatalogDefaultChatModel } from './model-catalog-choices'; +import { buildChatModelChoices, chatModelChoiceLabel, normalizeActiveChatModel } from './chat-model-selection'; +import type { ComposerDefaults } from './composer-defaults'; + +type NewChatModel = { llmConnectionSlug: string; model: string }; + +/** + * Owns every value the chat header + composer derive from the LLM-connection + * list and the active session: the resolved active connection/model labels, + * the shared model-choice list, the home / empty-state new-chat model + its + * sticky pick, the thinking-variant lists, and the two chat-header alerts. + * + * Pure move out of AppShell — every memo keeps its exact dependency array (so + * `chatModelChoices` / `activeThinkingLevels` / `newChatThinkingLevels` retain + * their referential-stability behavior) and the sticky-pick validation still + * drops a `pendingNewChatModel` that is no longer an offered choice. The + * `openSettingsSection` jump is injected so `chatConnectionAlert` can wrap the + * derived click target exactly as before; its memo deliberately omits the + * injected handler from the dep array (see the inline note). + */ +export function useShellChatModel(options: { + connections: LlmConnection[]; + defaultConnection: string | null; + activeSession: SessionSummary | undefined; + activeSessionEventHealth: SessionEventStreamSnapshot | undefined; + persistedComposerDefaults: ComposerDefaults | null; + openSettingsSection: (section: SettingsSection) => void; +}): { + chatModelChoices: ChatModelChoice[]; + activeConnection: LlmConnection | undefined; + activeConnectionLabel: string | undefined; + activeModel: string | undefined; + activeModelLabel: string | undefined; + activeThinkingLevels: readonly ThinkingLevel[]; + activeThinkingLevel: ThinkingLevel | undefined; + newChatModel: NewChatModel | undefined; + newChatModelLabel: string | undefined; + newChatThinkingLevels: readonly ThinkingLevel[]; + newChatThinkingLevel: ThinkingLevel | undefined; + validPendingNewChatModel: NewChatModel | null; + pendingNewChatModel: NewChatModel | null; + setPendingNewChatModel: (next: NewChatModel | null) => void; + pendingNewChatThinkingLevel: ThinkingLevel | null; + setPendingNewChatThinkingLevel: (next: ThinkingLevel | null) => void; + chatConnectionAlert: ChatHeaderAlert | undefined; + chatEventStreamAlert: ChatHeaderAlert | undefined; +} { + const { connections, defaultConnection, activeSession, activeSessionEventHealth, persistedComposerDefaults, openSettingsSection } = options; + // Persisted composer defaults seed the empty-state model so the home view is + // populated before the async `app:info` round-trip completes on mount. + const [pendingNewChatModel, setPendingNewChatModel] = useState( + persistedComposerDefaults?.model ?? null, + ); + const activeConnection = activeSession + ? connections.find((connection) => connection.slug === activeSession.llmConnectionSlug) + : undefined; + const defaultConnectionEntry = defaultConnection + ? connections.find((connection) => connection.slug === defaultConnection) + : undefined; + const chatModelChoices = useMemo( + () => buildChatModelChoices(connections), + [connections], + ); + // Home / empty-state composer: which model the next NEW chat starts with. + // Null = follow the default connection; a pick overrides it (sticky until + // changed) and is forwarded to sessions.create in `send()`. Renderer-only — + // it never mutates the persisted Settings · 模型 default. + const [pendingNewChatThinkingLevel, setPendingNewChatThinkingLevel] = useState(null); + // A pick only stays in effect while it is still an offered choice. If the user + // later disables/removes that connection or model, fall back to the default so + // the home chip never shows — nor sends — a model that no longer exists. + const validPendingNewChatModel = + pendingNewChatModel && + chatModelChoices.some( + (c) => c.connectionSlug === pendingNewChatModel.llmConnectionSlug && c.model === pendingNewChatModel.model, + ) + ? pendingNewChatModel + : null; + const catalogDefaultNewChatModel = defaultConnectionEntry + ? pickCatalogDefaultChatModel(defaultConnectionEntry) + : undefined; + const newChatModel = validPendingNewChatModel ?? catalogDefaultNewChatModel; + const activeConnectionLabel = activeSession?.backend === 'fake' + ? '本地模拟连接' + : activeConnection?.name ?? activeSession?.llmConnectionSlug; + const activeModel = activeSession?.backend === 'fake' + ? undefined + : normalizeActiveChatModel(activeSession, activeConnection, chatModelChoices); + const activeModelLabel = activeSession?.backend === 'fake' + ? undefined + : chatModelChoiceLabel(chatModelChoices, activeSession?.llmConnectionSlug, activeModel); + const activeThinkingLevels = useMemo( + () => (activeConnection && activeModel) ? thinkingVariantsForModel(activeConnection.providerType, activeModel) : [], + [activeConnection, activeModel], + ); + // Only surface a stored level when the current model still supports it; + // if the model changed (setModel clears it) or the catalog reconfigured so + // the level is no longer offered, the chip falls back to 默认 instead of + // advertising a level the runtime would silently drop. The runtime's + // `buildProviderOptions` is the wire-level guard; this keeps the UI honest. + const activeThinkingLevel = + activeSession?.thinkingLevel && activeThinkingLevels.includes(activeSession.thinkingLevel) + ? activeSession.thinkingLevel + : undefined; + const newChatThinkingLevels = useMemo( + () => { + if (!newChatModel) return []; + const c = connections.find((entry) => entry.slug === newChatModel.llmConnectionSlug); + return c ? thinkingVariantsForModel(c.providerType, newChatModel.model) : []; + }, + [newChatModel, connections], + ); + const newChatThinkingLevel = pendingNewChatThinkingLevel && newChatThinkingLevels.includes(pendingNewChatThinkingLevel) + ? pendingNewChatThinkingLevel + : undefined; + const newChatModelLabel = chatModelChoiceLabel(chatModelChoices, newChatModel?.llmConnectionSlug, newChatModel?.model); + + // Cheap renderer-side "is the default connection plausibly ready" check — + // used to decide whether a stale session can be silent-rebound on send + // (xuan's send-path rebind requires a ready default) or whether the user + // has to fix Settings first. We can't verify `hasSecret` synchronously + // here without an extra IPC round-trip; backend remains authoritative if + // the secret is missing — it will surface `missing_api_key` reason at + // send time. For banner copy purposes, "default exists + enabled" is + // enough. + const defaultConnectionReady = useMemo(() => { + if (!defaultConnection) return false; + const entry = connections.find((connection) => connection.slug === defaultConnection); + return entry?.enabled === true; + }, [defaultConnection, connections]); + + // Banner derivation is a pure function (see `chat-header-alert.ts`); we + // wrap the returned `onClickTarget` here with the Settings-jump action. + const chatConnectionAlert = useMemo(() => { + const derived = deriveChatHeaderAlert({ + backend: activeSession?.backend, + hasActiveConnection: Boolean(activeConnection), + defaultConnectionReady, + lastTestStatus: activeConnection?.lastTestStatus, + }); + if (!derived) return undefined; + const target = derived.onClickTarget; + return { + tone: derived.tone, + label: derived.label, + ...(derived.tooltip ? { tooltip: derived.tooltip } : {}), + onClick: () => openSettingsSection(target), + }; + // openSettingsSection is stable enough for our purposes — main.tsx + // doesn't depend on it changing, and including it would force the + // effect to re-create on every render due to its function identity. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + activeSession?.id, + activeSession?.backend, + activeConnection?.slug, + activeConnection?.lastTestStatus, + defaultConnectionReady, + ]); + + const chatEventStreamAlert = useMemo(() => { + if (activeSessionEventHealth?.status !== 'stale') return undefined; + return { + tone: 'warning', + label: '事件流恢复中', + tooltip: '当前对话的实时事件需要刷新,Maka 正在从本地会话记录恢复。', + }; + }, [activeSessionEventHealth?.status]); + + return { + chatModelChoices, + activeConnection, + activeConnectionLabel, + activeModel, + activeModelLabel, + activeThinkingLevels, + activeThinkingLevel, + newChatModel, + newChatModelLabel, + newChatThinkingLevels, + newChatThinkingLevel, + validPendingNewChatModel, + pendingNewChatModel, + setPendingNewChatModel, + pendingNewChatThinkingLevel, + setPendingNewChatThinkingLevel, + chatConnectionAlert, + chatEventStreamAlert, + }; +} diff --git a/apps/desktop/src/renderer/use-shell-live-turn.ts b/apps/desktop/src/renderer/use-shell-live-turn.ts new file mode 100644 index 0000000000..de4b49c0f4 --- /dev/null +++ b/apps/desktop/src/renderer/use-shell-live-turn.ts @@ -0,0 +1,107 @@ +import { useMemo } from 'react'; +import type { SessionSummary, ShellRunUpdate } from '@maka/core'; +import type { LiveTurnProjection, ToolActivityItem } from '@maka/ui'; +import type { ShellRunUpdatesBySession } from './shell-run-update-state'; +import { hasInFlightToolActivity } from './session-event-health'; +import { MODEL_CONTINUING_DELAY_MS, MODEL_PROCESSING_DELAY_MS, deriveModelWait, type ModelWaitKind } from './model-wait-state'; +import { useDelayedFlag } from './use-delayed-flag'; + +/** + * Owns everything the chat surface derives from the live-turn projection of the + * active session: the streaming/thinking text slices, the per-session streaming + * pulse set, the in-flight tool signal, and the two #646 turn-wait indicators. + * + * Pure move out of AppShell — `activeLiveTurn` itself stays in AppShell (a + * source-slice contract pins its declaration there) and is passed in, while the + * memos keep their exact dependency arrays so `activeShellRunUpdates`, + * `streamingSessionIds`, `liveTools`, and `hasInFlightLiveTools` retain their + * referential-stability behavior. The rising-edge delays (`useDelayedFlag`) + * suppress a flash on fast turns / quick step hops exactly as before. + */ +export function useShellLiveTurn(options: { + activeId: string | undefined; + activeLiveTurn: LiveTurnProjection | undefined; + liveTurnBySession: Record; + shellRunUpdatesBySession: ShellRunUpdatesBySession; + activeSession: SessionSummary | undefined; +}): { + activeShellRunUpdates: ShellRunUpdate[]; + activeStreaming: string; + activeStreamingComplete: boolean; + activeStreamingLive: boolean; + activeStreamingMessageId: string | undefined; + activeThinking: string; + streamingSessionIds: Set; + liveTools: ToolActivityItem[]; + hasInFlightLiveTools: boolean; + turnInFlight: boolean; + sessionAwaitingModel: boolean; + showProcessingIndicator: boolean; + showContinuingIndicator: boolean; +} { + const { activeId, activeLiveTurn, liveTurnBySession, shellRunUpdatesBySession, activeSession } = options; + const activeShellRunUpdates = useMemo( + () => activeId ? Object.values(shellRunUpdatesBySession[activeId] ?? {}) : [], + [activeId, shellRunUpdatesBySession], + ); + const activeTextStep = [...(activeLiveTurn?.steps ?? [])].reverse().find((step) => step.text); + const activeThinkingStep = [...(activeLiveTurn?.steps ?? [])].reverse().find((step) => step.thinking); + const activeStreaming = activeTextStep?.text?.text ?? ''; + const activeStreamingComplete = activeTextStep?.text?.complete === true; + const activeStreamingLive = activeStreaming.length > 0 && !activeStreamingComplete; + const activeStreamingMessageId = activeStreamingComplete ? activeTextStep?.stepId : undefined; + const activeThinking = activeThinkingStep?.thinking?.text ?? ''; + // Set of session ids with a live streaming delta — drives the sidebar + // pulse indicator. Recomputed on every live projection change; cheap + // since the underlying map only has at most a handful of entries. + const streamingSessionIds = useMemo( + () => new Set(Object.entries(liveTurnBySession).flatMap(([id, projection]) => ( + projection.steps.some((step) => step.text?.text && !step.text.complete) ? [id] : [] + ))), + [liveTurnBySession], + ); + const liveTools = useMemo(() => activeLiveTurn?.steps.flatMap((step) => step.tools) ?? [], [activeLiveTurn]); + const hasInFlightLiveTools = useMemo(() => hasInFlightToolActivity(liveTools), [liveTools]); + + // #646: the two turn-wait cues. `turnPhase` (armed at send, no lag; promoted to + // 'streamed' on the first content event) separates the connect-to-first-token + // wait from the later step-to-step lulls; the `status === 'running'` gate + // self-heals a backgrounded session whose terminal event was missed while + // inactive (its arm can't clear without the event). The rising-edge delays + // (useDelayedFlag) suppress a flash on fast turns / quick step hops. + const activeTurnPhase = activeLiveTurn?.terminal ? undefined : activeLiveTurn?.phase; + const turnInFlight = activeTurnPhase !== undefined; + const modelWaitKind: ModelWaitKind = deriveModelWait({ + turnPhase: activeTurnPhase, + streamingText: activeStreaming, + thinkingText: activeThinking, + hasInFlightTools: hasInFlightLiveTools, + }); + const sessionAwaitingModel = activeSession?.status === 'running'; + // The prominent "正在处理…" first-token indicator (turn head only). + const showProcessingIndicator = useDelayedFlag( + sessionAwaitingModel && modelWaitKind === 'processing', + MODEL_PROCESSING_DELAY_MS, + ); + // The calm "继续中…" hint for a mid-turn step-to-step lull (after content). + const showContinuingIndicator = useDelayedFlag( + sessionAwaitingModel && modelWaitKind === 'continuing', + MODEL_CONTINUING_DELAY_MS, + ); + + return { + activeShellRunUpdates, + activeStreaming, + activeStreamingComplete, + activeStreamingLive, + activeStreamingMessageId, + activeThinking, + streamingSessionIds, + liveTools, + hasInFlightLiveTools, + turnInFlight, + sessionAwaitingModel, + showProcessingIndicator, + showContinuingIndicator, + }; +} diff --git a/notes/frontend-simplification-map-2026-07-13.md b/notes/frontend-simplification-map-2026-07-13.md index 7824808a10..d6e4ceb42f 100644 --- a/notes/frontend-simplification-map-2026-07-13.md +++ b/notes/frontend-simplification-map-2026-07-13.md @@ -94,11 +94,69 @@ dist/**/*.test.js). Real finds verified by hand before acting. its knip ignore; the overlay-scrollbars contract's per-file assertion upgraded to a repo-wide ban on @base-ui react scroll-area imports (stronger invariant, no coverage lost). -- [ ] **D-2 — mounted-guard long tail**: ~30 remaining `*MountedRef` sites across - renderer settings pages and packages/ui panels (see `grep -ri "mountedref = useRef"`). - Mechanical agent sweep: swap to useMountedRef, keep per-site companion-ref cleanup - effects, re-pin any contract that quotes the old shape. Watch the useRef(false) - variants — verify no pre-effect reads before flipping initial value semantics. +- [x] **D-2 — SHIPPED: mounted-guard long tail.** Converted 34 of the 35 census + `*MountedRef` sites to `useMountedRef` — 20 renderer settings sites, 4 other + renderer sites (OnboardingHero readyHero, FirstRunChecklist, artifact-pane, + browser-panel), 10 packages/ui panels (chat-turn, chat-model-switcher, search-modal, + plan-reminder-panel, clipboard-feedback, skills-panel, composer, permission-dialog, + session-history-list, daily-review-panel). Kept each site's companion-ref cleanup + effect and its ref name; deleted the whole effect only where it did nothing but the + mounted flag (browser-panel, permission-dialog). packages/ui sites import the hook + from `./use-mounted-ref.js` per house style. Re-pinned ~30 contract assertions across + ~20 test files to the shared-hook shape (definition lines + effect blocks). The + useRef(false) variants all read the flag only inside async handlers — no pre-effect + reads — so flipping to true-initial semantics is behavior-preserving. Deliberately + NOT converted: use-memory-settings-controller (lifecycle-counter variant — cleanup + reset is guarded by a lifecycle counter and reads combine mounted with lifecycle + equality, same shape as use-workspace-instructions-controller) and app-shell.tsx + rendererMountedRef (Round B owns that file). +- [~] **E — app-shell derived-value extraction (Round B follow-on) — blade 1 SHIPPED, + blade 2 SKIPPED. branch refactor/app-shell-view-split. app-shell.tsx 1680 → 1562.** + 1. **Derived-value extraction — SHIPPED (2 commits).** The whole ~210-line derived + block moved into two pure-derivation hooks following the use-shell-connections / + use-project-context house style, zero behavior change, every memo keeping its exact + dep array + referential stability: + - `use-shell-chat-model.ts` (195 lines): model/thinking selection (chatModelChoices, + active/new-chat model+label, thinking-variant lists, sticky-pick validation, both + pending new-chat states) + the two chat-header alert memos. openSettingsSection is + injected so chatConnectionAlert keeps its identical exhaustive-deps-excluded memo. + - `use-shell-live-turn.ts` (112 lines): live-turn projection (activeShellRunUpdates, + streaming/thinking slices, streamingSessionIds pulse set, liveTools/ + hasInFlightLiveTools) + the #646 turn-wait cues. `activeLiveTurn` stays pinned in + app-shell.tsx (streaming-timeline source-slice contract) and is passed in. + Re-pinned: added both files to renderer-shell-source-helpers combined allowlist; + added use-shell-chat-model.ts to the composer-new-chat model-picker contract's two + subset reads (pendingNewChatModel / validPendingNewChatModel / newChatThinkingLevel + declarations moved into the hook). All other model/live-turn contracts read combined + source and auto-followed via the allowlist. + 2. **JSX return split — SKIPPED (disproportionately risky + net-negative, per the + ship-what's-done rule; Round B skip note is the model).** The ~340-line return's + content area is irreducibly coupled to ~110 AppShell locals, and every meaningful + chunk is pinned to app-shell.tsx by a DIRECT-read (non-combined) contract: ChatView + `liveTurn={activeLiveTurn}` (streaming-timeline), Composer `onPickAttachments`/ + `onAttachFilePaths` (attachment-frontend) + `onPickNewChatModel` (composer-new-chat), + SessionListPanel `statusGroups={sessionListGroups}` (session-project-view), the + DailyReviewPage `onCopyMarkdown` (daily-review-copy-feedback), and the onboarding + `onSkip` handler (onboarding-one-time-regression). Extracting any of them into a + layout sub-component (a) forces ~50–110 props of straight-through drilling that ADDS + net interface/destructure boilerplate rather than pruning it (counter to the round's + goal), (b) requires structurally re-pinning 5+ behavioral contracts that specifically + assert "the shell orchestrator wires handler X into element Y", and (c) still lands + app-shell.tsx ≈ 1300 — the ≤1100 target is not safely reachable without over- + extraction under the zero-behavior mandate. Left in place with this note; the + derived-value extraction (blade 1) captures the genuine simplification. + Gates (each commit): desktop 2399/2399, ui 125/125, full typecheck 0, check-dead-css + clean, knip desktop+ui 0. Final: alignment auditor (AUDIT_PORT_BASE=19900) exit 0, all + 9 fixtures clean. CDP branch-vs-baseline captures (real Electron, light+dark 1280) of + turn-narrative / module-skills / settings-general / first-run: module-skills + first-run + byte-identical; settings-general independently non-deterministic (baseline itself yields + two hashes across repeated passes — pre-existing capture flake, unrelated); turn-narrative + differs ONLY in a 311×24px region at the very bottom of the frame — the composer footer + git-branch chip, which renders the worktree's real HEAD (the baseline had to be captured + in DETACHED-HEAD state since the `main` branch ref is held by the concurrent worktree, so + the chip shows `—` vs the feature-branch name). Pixel-diff bbox confirmed x:[1106-1417] + y:[1570-1594]; every other pixel identical, and the chip-less fixtures are byte-identical + — i.e. the derivation extraction is a proven render no-op. Update checkboxes as rounds ship. Every round: suite + typecheck + dead-css + alignment auditor + CDP spot captures, exit-code gated. diff --git a/packages/ui/src/chat-model-switcher.tsx b/packages/ui/src/chat-model-switcher.tsx index e2f8a46a6d..694b22179f 100644 --- a/packages/ui/src/chat-model-switcher.tsx +++ b/packages/ui/src/chat-model-switcher.tsx @@ -9,6 +9,7 @@ */ import { type ReactNode, useEffect, useRef, useState } from 'react'; +import { useMountedRef } from './use-mounted-ref.js'; import { Menu, MenuItem, MenuPopup, MenuTrigger } from './primitives/menu.js'; import { Button as UiButton } from './ui.js'; import { ModelPicker } from './model-picker.js'; @@ -135,7 +136,7 @@ export function ChatModelSwitcher(props: { }) { const [localPending, setLocalPending] = useState(false); const pendingRef = useRef(false); - const modelSwitcherMountedRef = useRef(true); + const modelSwitcherMountedRef = useMountedRef(); const pendingModelChangeRef = useRef<{ sessionId: string; token: number } | null>(null); const pendingModelChangeTokenRef = useRef(0); const currentModel = props.activeModel ?? props.activeSession.model; @@ -153,9 +154,7 @@ export function ChatModelSwitcher(props: { : props.disabledReason ?? `${currentSessionModelTitle}。设置里的默认模型只影响新建会话;这里会更新当前会话。`; useEffect(() => { - modelSwitcherMountedRef.current = true; return () => { - modelSwitcherMountedRef.current = false; pendingModelChangeRef.current = null; pendingModelChangeTokenRef.current += 1; pendingRef.current = false; diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index 758b314d3e..3533abff5a 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -1,4 +1,5 @@ import { Fragment, memo, useEffect, useRef, useState, type ReactNode } from 'react'; +import { useMountedRef } from './use-mounted-ref.js'; import { AlertOctagon, Ban, Brain, Check, ChevronRight, Copy, GitBranch, Info, Loader2, RefreshCcw, Timer } from './icons.js'; import { type ClipboardCopyPhase, useClipboardCopyFeedback } from './clipboard-feedback.js'; import { Markdown } from './markdown.js'; @@ -503,7 +504,7 @@ function TurnFooterActions(props: { const [copyPhase, setCopyPhase] = useState(null); const copyPendingRef = useRef(false); const copyResetTimerRef = useRef(null); - const copyMountedRef = useRef(true); + const copyMountedRef = useMountedRef(); function clearCopyResetTimer() { if (copyResetTimerRef.current === null) return; @@ -512,9 +513,7 @@ function TurnFooterActions(props: { } useEffect(() => { - copyMountedRef.current = true; return () => { - copyMountedRef.current = false; clearCopyResetTimer(); }; }, []); diff --git a/packages/ui/src/clipboard-feedback.ts b/packages/ui/src/clipboard-feedback.ts index 47fc8c76ab..b0af9825ac 100644 --- a/packages/ui/src/clipboard-feedback.ts +++ b/packages/ui/src/clipboard-feedback.ts @@ -30,6 +30,7 @@ */ import { useEffect, useRef, useState } from 'react'; +import { useMountedRef } from './use-mounted-ref.js'; import { redactSecrets } from './redact.js'; export type ClipboardCopyPhase = 'pending' | 'copied' | 'failed'; @@ -37,7 +38,7 @@ export type ClipboardCopyPhase = 'pending' | 'copied' | 'failed'; export function useClipboardCopyFeedback(resetDelay = 1400, options: { redact?: boolean } = {}) { const [copyState, setCopyState] = useState<{ key: string; phase: ClipboardCopyPhase } | null>(null); const pendingCopyRef = useRef(null); - const copyMountedRef = useRef(true); + const copyMountedRef = useMountedRef(); const resetTimerRef = useRef(null); function clearResetTimer() { @@ -47,9 +48,7 @@ export function useClipboardCopyFeedback(resetDelay = 1400, options: { redact?: } useEffect(() => { - copyMountedRef.current = true; return () => { - copyMountedRef.current = false; clearResetTimer(); }; }, []); diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index 0045ea83df..9583360078 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -10,6 +10,7 @@ import { type KeyboardEvent, type ReactNode, } from 'react'; +import { useMountedRef } from './use-mounted-ref.js'; import { ArrowUp, Check, ChevronDown, FileEdit, FolderOpen, GitBranch, History, Plus } from './icons.js'; import { ChatModelSwitcher, ModelChipStatic, NewChatModelPicker } from './chat-model-switcher.js'; import { type UiLocale, detectUiLocale } from './locale-helpers.js'; @@ -228,7 +229,7 @@ export const Composer = forwardRef< const [hasDraftText, setHasDraftText] = useState(false); const draftStoreRef = useRef>(new Map()); const activeDraftKeyRef = useRef(props.draftKey); - const composerMountedRef = useRef(true); + const composerMountedRef = useMountedRef(); const sendPendingRef = useRef(false); const compositionActiveRef = useRef(false); const importActionOwnerRef = useRef | null>(null); @@ -248,9 +249,7 @@ export const Composer = forwardRef< const buttonCopy = COMPOSER_BUTTON_COPY_BY_LOCALE[locale]; useEffect(() => { - composerMountedRef.current = true; return () => { - composerMountedRef.current = false; sendPendingRef.current = false; importActionOwnerRef.current?.reset(); }; diff --git a/packages/ui/src/daily-review-panel.tsx b/packages/ui/src/daily-review-panel.tsx index d0f56f65a1..45ff4fb6ff 100644 --- a/packages/ui/src/daily-review-panel.tsx +++ b/packages/ui/src/daily-review-panel.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react'; +import { useMountedRef } from './use-mounted-ref.js'; import { CalendarDays } from './icons.js'; import { SettingsSelect } from './primitives/settings-select.js'; import type { @@ -90,7 +91,7 @@ export function DailyReviewPanel(props: { const [archiveReloadToken, setArchiveReloadToken] = useState(0); const modelOptions = useMemo(() => props.bridge.modelOptions ?? EMPTY_MODEL_OPTIONS, [props.bridge.modelOptions]); const [selectedModelKey, setSelectedModelKey] = useState(modelOptions[0]?.[0] ?? ''); - const dailyReviewMountedRef = useRef(true); + const dailyReviewMountedRef = useMountedRef(); const summaryScopeKeyRef = useRef(null); const pendingDailyReviewActionRef = useRef(null); const archiveLoadRequestRef = useRef(0); @@ -106,9 +107,7 @@ export function DailyReviewPanel(props: { const canLoadArchives = Boolean(props.bridge.listArchives && props.bridge.getArchive); useEffect(() => { - dailyReviewMountedRef.current = true; return () => { - dailyReviewMountedRef.current = false; pendingDailyReviewActionRef.current = null; archiveLoadRequestRef.current += 1; }; diff --git a/packages/ui/src/permission-dialog.tsx b/packages/ui/src/permission-dialog.tsx index 1593bf6f77..e12f6760d0 100644 --- a/packages/ui/src/permission-dialog.tsx +++ b/packages/ui/src/permission-dialog.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { useMountedRef } from './use-mounted-ref.js'; import type { PermissionRequestEvent, PermissionResponse } from '@maka/core'; import { derivePermissionRequestHealth, formatPermissionRequestWait, readWriteStdinInputPreview } from '@maka/core'; import { Collapsible, CollapsibleTrigger, CollapsiblePanel } from './primitives/collapsible.js'; @@ -42,16 +43,9 @@ export function PermissionPrompt(props: { const [now, setNow] = useState(() => Date.now()); const responsePendingRef = useRef(false); const denyButtonRef = useRef(null); - const permissionMountedRef = useRef(true); + const permissionMountedRef = useMountedRef(); const activePermissionRequestIdRef = useRef(props.request.requestId); - useEffect(() => { - permissionMountedRef.current = true; - return () => { - permissionMountedRef.current = false; - }; - }, []); - useEffect(() => { activePermissionRequestIdRef.current = props.request.requestId; setRememberForTurn(false); diff --git a/packages/ui/src/plan-reminder-panel.tsx b/packages/ui/src/plan-reminder-panel.tsx index 561ec45f3b..532d3ef6c9 100644 --- a/packages/ui/src/plan-reminder-panel.tsx +++ b/packages/ui/src/plan-reminder-panel.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, type FormEvent } from 'react'; +import { useMountedRef } from './use-mounted-ref.js'; import { ArchiveRestore, Check, @@ -133,7 +134,7 @@ export function PlanReminderPanel(props: { const [editingId, setEditingId] = useState(null); const [submitPending, setSubmitPending] = useState(false); const [pendingActionKeys, setPendingActionKeys] = useState>(() => new Set()); - const planReminderMountedRef = useRef(true); + const planReminderMountedRef = useMountedRef(); const submitPendingRef = useRef(false); const refreshPendingRef = useRef(false); const pendingActionKeysRef = useRef>(new Set()); @@ -185,9 +186,7 @@ export function PlanReminderPanel(props: { const auditReport = props.auditReport ?? deriveCapabilityAuditReport({ planReminders: props.reminders }); useEffect(() => { - planReminderMountedRef.current = true; return () => { - planReminderMountedRef.current = false; submitPendingRef.current = false; refreshPendingRef.current = false; pendingActionKeysRef.current = new Set(); diff --git a/packages/ui/src/search-modal.tsx b/packages/ui/src/search-modal.tsx index 8d4a92e207..288c6f4ac6 100644 --- a/packages/ui/src/search-modal.tsx +++ b/packages/ui/src/search-modal.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'; +import { useMountedRef } from './use-mounted-ref.js'; import type { SearchErrorReason, SearchRequest, SearchResult } from '@maka/core'; import { generalizedErrorMessageChinese } from '@maka/core'; import { Autocomplete } from '@base-ui/react/autocomplete'; @@ -116,14 +117,12 @@ export function SearchModal(props: { const [pending, setPending] = useState(false); const inputRef = useRef(null); const ticketRef = useRef(0); - const searchMountedRef = useRef(true); + const searchMountedRef = useMountedRef(); const searchThread = props.deps?.searchThread; const suppressFocusRestoreRef = useRef(false); useEffect(() => { - searchMountedRef.current = true; return () => { - searchMountedRef.current = false; ticketRef.current += 1; }; }, []); diff --git a/packages/ui/src/session-history-list.tsx b/packages/ui/src/session-history-list.tsx index 16bc93eaa8..1219b8e4de 100644 --- a/packages/ui/src/session-history-list.tsx +++ b/packages/ui/src/session-history-list.tsx @@ -1,4 +1,5 @@ import { memo, useEffect, useRef, useState, type FocusEvent, type KeyboardEvent } from 'react'; +import { useMountedRef } from './use-mounted-ref.js'; import type { SessionSummary } from '@maka/core'; import { formatCompactTimestamp } from '@maka/core'; import { @@ -482,7 +483,7 @@ const SessionRow = memo(function SessionRow(props: { const [actionsVisible, setActionsVisible] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const [pendingAction, setPendingAction] = useState(null); - const rowMountedRef = useRef(true); + const rowMountedRef = useMountedRef(); const pendingActionRef = useRef(null); const inputRef = useRef(null); // PR-FE-BUG-HUNT-11: Escape on the rename input has to suppress the @@ -495,9 +496,7 @@ const SessionRow = memo(function SessionRow(props: { const actionTriggerVisible = actionsVisible || menuOpen; useEffect(() => { - rowMountedRef.current = true; return () => { - rowMountedRef.current = false; pendingActionRef.current = null; }; }, []); diff --git a/packages/ui/src/skills-panel.tsx b/packages/ui/src/skills-panel.tsx index bda604b818..0821e4e261 100644 --- a/packages/ui/src/skills-panel.tsx +++ b/packages/ui/src/skills-panel.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { useMountedRef } from './use-mounted-ref.js'; import { Blocks, BookOpen, @@ -704,13 +705,11 @@ export function SkillsModuleMain(props: { }) { const [pendingSkillAction, setPendingSkillAction] = useState(null); const [skillSearchQuery, setSkillSearchQuery] = useState(''); - const skillActionMountedRef = useRef(true); + const skillActionMountedRef = useMountedRef(); const pendingSkillActionRef = useRef(null); useEffect(() => { - skillActionMountedRef.current = true; return () => { - skillActionMountedRef.current = false; pendingSkillActionRef.current = null; }; }, []);