diff --git a/apps/desktop/electron/updater-process.ts b/apps/desktop/electron/updater-process.ts index 86ff2ab653..04eef34f64 100644 --- a/apps/desktop/electron/updater-process.ts +++ b/apps/desktop/electron/updater-process.ts @@ -10,10 +10,14 @@ export function windowsUpdatePrerequisiteError(updateRoot: string): string | nul const required = [path.join(updateRoot, 'venv', 'Scripts', 'python.exe')] // Pre-reorg flat scripts remain supported; damaged modern trees do not. - if (existsSync(maintainedDir)) {required.push(path.join(maintainedDir, 'windows.ps1'))} + if (existsSync(maintainedDir)) { + required.push(path.join(maintainedDir, 'windows.ps1')) + } for (const candidate of required) { - if (stagedFileExists(candidate)) {continue} + if (stagedFileExists(candidate)) { + continue + } return `Update aborted: ${candidate} is missing or unreadable. Repair the installation and review antivirus quarantine before retrying.` } diff --git a/apps/desktop/src/api/config.ts b/apps/desktop/src/api/config.ts index 88c63ada61..8db95b1556 100644 --- a/apps/desktop/src/api/config.ts +++ b/apps/desktop/src/api/config.ts @@ -205,7 +205,10 @@ export function listOAuthProviders(profile?: null | string): Promise { +export function disconnectOAuthProvider( + providerId: string, + profile?: null | string +): Promise<{ ok: boolean; provider: string }> { return hermesApi<{ ok: boolean; provider: string }>({ ...profileScoped(profile), path: `/api/providers/oauth/${encodeURIComponent(providerId)}`, @@ -222,7 +225,12 @@ export function startOAuthLogin(providerId: string, profile?: ProfileScope): Pro }) } -export function submitOAuthCode(providerId: string, sessionId: string, code: string, profile?: null | string): Promise { +export function submitOAuthCode( + providerId: string, + sessionId: string, + code: string, + profile?: null | string +): Promise { return hermesApi({ ...profileScoped(profile), path: `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`, diff --git a/apps/desktop/src/app/chat/composer/trigger-popover-description.test.tsx b/apps/desktop/src/app/chat/composer/trigger-popover-description.test.tsx index eccb7e75fa..a56dfba453 100644 --- a/apps/desktop/src/app/chat/composer/trigger-popover-description.test.tsx +++ b/apps/desktop/src/app/chat/composer/trigger-popover-description.test.tsx @@ -13,7 +13,14 @@ it('reveals the complete slash description on hover without intercepting selecti const onPick = vi.fn() render( - + ) const row = screen.getByRole('button') diff --git a/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx b/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx index 5b4a294313..52a0df6ee9 100644 --- a/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx +++ b/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx @@ -199,4 +199,4 @@ describe('ComposerTriggerPopover keyboard scrolling', () => { expect(drawer.scrollTop).toBe(30) }) -}) \ No newline at end of file +}) diff --git a/apps/desktop/src/app/chat/composer/trigger-popover.tsx b/apps/desktop/src/app/chat/composer/trigger-popover.tsx index ddc29d86f9..4e575d3e3a 100644 --- a/apps/desktop/src/app/chat/composer/trigger-popover.tsx +++ b/apps/desktop/src/app/chat/composer/trigger-popover.tsx @@ -230,7 +230,9 @@ export function ComposerTriggerPopover({ {display} {description && ( - {description} + + {description} + )} )} diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index e4e4fbd176..611a5a4d45 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -175,7 +175,12 @@ import { useRepoWorktreeMap } from './projects' import { WorktreeDialog } from './projects/worktree-dialog' -import { SidebarBlankState, SidebarLoadErrorState, SidebarPinnedEmptyState, SidebarSessionSkeletons } from './section-states' +import { + SidebarBlankState, + SidebarLoadErrorState, + SidebarPinnedEmptyState, + SidebarSessionSkeletons +} from './section-states' import { buildSessionByAnyId, resolvePinnedSessions } from './session-index' import { SidebarSessionsSection, VIRTUALIZE_THRESHOLD } from './sessions-section' import { CONTEXT_SPLIT_KIT, SplitSubmenu } from './split-submenu' @@ -987,8 +992,12 @@ export function ChatSidebar({ // Entering a project lazily hydrates its full lanes (repo -> lane -> sessions) // from the backend — same grouping/ids as the overview, just with rows. - const { project: enteredProjectTree, failed: projectLoadFailed, loading: projectLoading, retry: retryProject } = - useEnteredProjectSessions(enteredProjectId, gatewayReady, projectTree, `${activeConnectionId}:${profileScope}`) + const { + project: enteredProjectTree, + failed: projectLoadFailed, + loading: projectLoading, + retry: retryProject + } = useEnteredProjectSessions(enteredProjectId, gatewayReady, projectTree, `${activeConnectionId}:${profileScope}`) // Prefer the hydrated tree; fall back to the overview node (empty lanes) while // the drill-in fetch is in flight, so the header/structure render immediately. diff --git a/apps/desktop/src/app/chat/sidebar/use-entered-project-sessions.test.ts b/apps/desktop/src/app/chat/sidebar/use-entered-project-sessions.test.ts index 7a386ca84e..047336035c 100644 --- a/apps/desktop/src/app/chat/sidebar/use-entered-project-sessions.test.ts +++ b/apps/desktop/src/app/chat/sidebar/use-entered-project-sessions.test.ts @@ -11,16 +11,20 @@ afterEach(cleanup) it('ignores departed drill-ins and clears failure on retry', async () => { let failOld!: (error: Error) => void vi.mocked(fetchProjectSessions) - .mockImplementationOnce(() => new Promise((_, reject) => { failOld = reject })) + .mockImplementationOnce( + () => + new Promise((_, reject) => { + failOld = reject + }) + ) .mockResolvedValueOnce(null) .mockRejectedValueOnce(new Error('failed')) .mockResolvedValueOnce(null) const tree: never[] = [] - const { result, rerender } = renderHook( - ({ id }) => useEnteredProjectSessions(id, true, tree, 'default'), - { initialProps: { id: 'old' } } - ) + const { result, rerender } = renderHook(({ id }) => useEnteredProjectSessions(id, true, tree, 'default'), { + initialProps: { id: 'old' } + }) rerender({ id: 'current' }) await waitFor(() => expect(result.current.loading).toBe(false)) diff --git a/apps/desktop/src/app/chat/sidebar/use-entered-project-sessions.ts b/apps/desktop/src/app/chat/sidebar/use-entered-project-sessions.ts index f4b37e9d45..a86fb130b3 100644 --- a/apps/desktop/src/app/chat/sidebar/use-entered-project-sessions.ts +++ b/apps/desktop/src/app/chat/sidebar/use-entered-project-sessions.ts @@ -17,7 +17,9 @@ export function useEnteredProjectSessions( const [loading, setLoading] = useState(false) const [retryToken, setRetryToken] = useState(0) - useEffect(() => { setProject(null) }, [projectId, scope]) + useEffect(() => { + setProject(null) + }, [projectId, scope]) useEffect(() => { let cancelled = false @@ -32,11 +34,25 @@ export function useEnteredProjectSessions( setLoading(true) void fetchProjectSessions(projectId) - .then(next => { if (!cancelled) { setProject(next) } }) - .catch(() => { if (!cancelled) { setFailed(true) } }) - .finally(() => { if (!cancelled) { setLoading(false) } }) + .then(next => { + if (!cancelled) { + setProject(next) + } + }) + .catch(() => { + if (!cancelled) { + setFailed(true) + } + }) + .finally(() => { + if (!cancelled) { + setLoading(false) + } + }) - return () => { cancelled = true } + return () => { + cancelled = true + } }, [projectId, ready, treeRevision, scope, retryToken]) return { project, failed, loading, retry: () => setRetryToken(token => token + 1) } diff --git a/apps/desktop/src/app/shell/model-catalog-menu.search-fold.test.tsx b/apps/desktop/src/app/shell/model-catalog-menu.search-fold.test.tsx index 41cda53d03..af60a22025 100644 --- a/apps/desktop/src/app/shell/model-catalog-menu.search-fold.test.tsx +++ b/apps/desktop/src/app/shell/model-catalog-menu.search-fold.test.tsx @@ -61,12 +61,14 @@ function renderMenu() { ) } - it('matches and highlights separator-equivalent queries without revealing unrelated models', async () => { renderMenu() await screen.findByText(/Qwen3\.8 Flash/i) - for (const [query, marked] of [['qwen3.8-flash', 'Qwen3.8 Flash'], ['qwen3 8', 'Qwen3.8']]) { + for (const [query, marked] of [ + ['qwen3.8-flash', 'Qwen3.8 Flash'], + ['qwen3 8', 'Qwen3.8'] + ]) { fireEvent.change(screen.getByRole('textbox', { name: 'Search models' }), { target: { value: query } }) await vi.waitFor(() => { expect(screen.getByText(marked, { selector: 'mark' })).not.toBeNull() diff --git a/apps/desktop/src/components/assistant-ui/thread/list-session-scroll.test.tsx b/apps/desktop/src/components/assistant-ui/thread/list-session-scroll.test.tsx index 5b831ae7c6..ad1b6a57d3 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list-session-scroll.test.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/list-session-scroll.test.tsx @@ -169,5 +169,4 @@ describe('list session-scroll restore', () => { // resulting reader position. Here the abandoned target must not return. expect(vp.scrollTop).not.toBe(2000 - CLIENT_H - 800) }) - }) diff --git a/apps/desktop/src/components/model-picker.tsx b/apps/desktop/src/components/model-picker.tsx index 3beaa8428d..f4803e8786 100644 --- a/apps/desktop/src/components/model-picker.tsx +++ b/apps/desktop/src/components/model-picker.tsx @@ -265,10 +265,7 @@ function ModelResults({ const q = normalize(search) const matches = (provider: ModelOptionProvider, model: string) => - !q || - foldIncludes(modelSearchText(model), q) || - foldIncludes(provider.name, q) || - foldIncludes(provider.slug, q) + !q || foldIncludes(modelSearchText(model), q) || foldIncludes(provider.name, q) || foldIncludes(provider.slug, q) // Only configured providers (those with curated models) are selectable // here. Switching to a NOT-yet-configured provider goes through the diff --git a/apps/desktop/src/components/onboarding/index.tsx b/apps/desktop/src/components/onboarding/index.tsx index 64ccd7f4c6..d18c819753 100644 --- a/apps/desktop/src/components/onboarding/index.tsx +++ b/apps/desktop/src/components/onboarding/index.tsx @@ -198,13 +198,16 @@ export function DesktopOnboardingOverlay({ const targetProfile = onboarding.targetProfile ?? profile // Async flows retain the initiating route even after the overlay closes. - const ctx = useMemo(() => ({ - profile: targetProfile, - requestGateway: onboarding.targetProfile - ? (method, params) => requestGatewayForProfile(targetProfile, method, params) - : requestGateway, - onCompleted: () => onCompletedRef.current?.() - }), [onboarding.targetProfile, targetProfile, requestGateway]) + const ctx = useMemo( + () => ({ + profile: targetProfile, + requestGateway: onboarding.targetProfile + ? (method, params) => requestGatewayForProfile(targetProfile, method, params) + : requestGateway, + onCompleted: () => onCompletedRef.current?.() + }), + [onboarding.targetProfile, targetProfile, requestGateway] + ) // Cinematic exit on "Begin": dissolve the panel + overlay (revealing the chat // behind), THEN finalize so the unmount lands after the fade — mirrors the diff --git a/apps/desktop/src/components/ui/highlight-matches.test.tsx b/apps/desktop/src/components/ui/highlight-matches.test.tsx index 2747fc74d8..2c9f158c75 100644 --- a/apps/desktop/src/components/ui/highlight-matches.test.tsx +++ b/apps/desktop/src/components/ui/highlight-matches.test.tsx @@ -42,4 +42,4 @@ describe('HighlightMatches', () => { expect(marksOf(container)).toEqual(['Grok']) }) -}) \ No newline at end of file +}) diff --git a/apps/desktop/src/store/gateway-profile-only-owner.integration.test.ts b/apps/desktop/src/store/gateway-profile-only-owner.integration.test.ts index 931180a353..c135806d9a 100644 --- a/apps/desktop/src/store/gateway-profile-only-owner.integration.test.ts +++ b/apps/desktop/src/store/gateway-profile-only-owner.integration.test.ts @@ -15,7 +15,9 @@ vi.mock('@/hermes', async importActual => ({ setApiRequestConnection: vi.fn(), HermesGateway: class { connectionState = 'closed' - private eventHandlers = new Set<(event: { payload?: Record; session_id?: string; type: string }) => void>() + private eventHandlers = new Set< + (event: { payload?: Record; session_id?: string; type: string }) => void + >() request = vi.fn(async (method: string, params: Record) => ({ method, params })) constructor() { @@ -58,7 +60,8 @@ const { const { $profiles } = await import('./profile') -const { $activeSessionId, _resetSessionOwnerHintsForTests, setSessionOwnerHint, setSessions } = await import('./session') +const { $activeSessionId, _resetSessionOwnerHintsForTests, setSessionOwnerHint, setSessions } = + await import('./session') const { $gateway } = await import('./gateway') const { clearAllPrompts } = await import('./prompts') @@ -160,8 +163,9 @@ describe('profile-only secondary approval ownership', () => { it('rejects unproven profiles and retires only transient local ownership, never durable exact routes', async () => { recordSessionEventScope({ profile: 'research', session_id: 'rt-unproven' }) expect(knownOwnerForSession('rt-unproven')).toBeUndefined() - await expect(requestForOwnedSession('rt-unproven', vi.fn() as never, 'approval.respond', {})) - .rejects.toSatisfy(isSessionOwnerResolutionError) + await expect(requestForOwnedSession('rt-unproven', vi.fn() as never, 'approval.respond', {})).rejects.toSatisfy( + isSessionOwnerResolutionError + ) await ensureGatewayForProfile('research') const secondary = gatewayMocks.instances[0] @@ -178,8 +182,9 @@ describe('profile-only secondary approval ownership', () => { expect(knownOwnerForSession('rt-remote')).toEqual(exact) const getConnection = window.hermesDesktop!.getConnection vi.mocked(getConnection).mockClear() - await expect(requestForOwnedSession('rt-retired', vi.fn() as never, 'approval.respond', {})) - .rejects.toSatisfy(isSessionOwnerResolutionError) + await expect(requestForOwnedSession('rt-retired', vi.fn() as never, 'approval.respond', {})).rejects.toSatisfy( + isSessionOwnerResolutionError + ) expect(getConnection).not.toHaveBeenCalled() }) }) diff --git a/apps/desktop/src/store/onboarding.test.ts b/apps/desktop/src/store/onboarding.test.ts index 740a1659be..e52aca39d6 100644 --- a/apps/desktop/src/store/onboarding.test.ts +++ b/apps/desktop/src/store/onboarding.test.ts @@ -81,28 +81,45 @@ function fallbackTimeoutGateway(): OnboardingContext['requestGateway'] { describe('refreshOnboarding', () => { it('keeps onboarding work in its initiating lifetime and profile', async () => { - const { startManualOnboarding, startProviderOAuth, saveOnboardingApiKey, closeManualOnboarding } = await import('./onboarding') + const { startManualOnboarding, startProviderOAuth, saveOnboardingApiKey, closeManualOnboarding } = + await import('./onboarding') const requests: { path: string; profile?: string }[] = [] let release!: () => void let delayKey = true installApiMock(async request => { requests.push(request) - if (request.path === '/api/providers/oauth') { return { providers: [] } } - - if (request.path.endsWith('/start')) { - return { flow: 'device_code', session_id: 'local-fixture', user_code: 'FAKE', verification_url: 'http://localhost/fixture', expires_in: 600 } + if (request.path === '/api/providers/oauth') { + return { providers: [] } } - if (request.path.includes('/poll/')) { return { status: 'approved' } } + if (request.path.endsWith('/start')) { + return { + flow: 'device_code', + session_id: 'local-fixture', + user_code: 'FAKE', + verification_url: 'http://localhost/fixture', + expires_in: 600 + } + } - if (request.path === '/api/env' && delayKey) { await new Promise(resolve => { release = resolve }) } + if (request.path.includes('/poll/')) { + return { status: 'approved' } + } + + if (request.path === '/api/env' && delayKey) { + await new Promise(resolve => { + release = resolve + }) + } if (request.path.startsWith('/api/model/options')) { return { providers: [{ slug: 'fixture', name: 'Fixture', models: ['fixture-model'] }] } } - if (request.path.startsWith('/api/model/recommended-default')) { return { model: 'fixture-model' } } + if (request.path.startsWith('/api/model/recommended-default')) { + return { model: 'fixture-model' } + } return { ok: true } }) @@ -110,8 +127,11 @@ describe('refreshOnboarding', () => { let profile = 'beta' const ctx: OnboardingContext = { - get profile() { return profile }, - requestGateway: async method => (method === 'setup.status' ? { provider_configured: true } : { ok: true }) as never + get profile() { + return profile + }, + requestGateway: async method => + (method === 'setup.status' ? { provider_configured: true } : { ok: true }) as never } try { diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts index ae58ba58f6..486655648e 100644 --- a/apps/desktop/src/store/onboarding.ts +++ b/apps/desktop/src/store/onboarding.ts @@ -341,10 +341,14 @@ async function completeWithModelConfirm( const generation = flowGeneration await ctx.requestGateway('reload.env').catch(() => undefined) - if (generation !== flowGeneration) { return } + if (generation !== flowGeneration) { + return + } const defaults = await fetchProviderDefaultModel(preferredSlugs, ctx.profile) - if (generation !== flowGeneration) { return } + if (generation !== flowGeneration) { + return + } if (defaults) { // Persist the chosen provider/model before the runtime gate so a stale @@ -362,10 +366,14 @@ async function completeWithModelConfirm( { skipConfirmPrompt: true } ) - if (generation !== flowGeneration) { return } + if (generation !== flowGeneration) { + return + } notifyGatewayTools(res.gateway_tools) } catch (error) { - if (generation !== flowGeneration) { return } + if (generation !== flowGeneration) { + return + } onFail(error instanceof Error ? error.message : 'Hermes could not save the selected model.') return @@ -374,7 +382,9 @@ async function completeWithModelConfirm( const runtime = await checkRuntime(ctx, preferredSlugs[0]) - if (generation !== flowGeneration) { return } + if (generation !== flowGeneration) { + return + } if (!runtime.ready && !ignoreRuntimeGate) { onFail(runtime.reason) @@ -420,13 +430,19 @@ async function refreshProviders() { try { const { providers } = await listOAuthProviders($desktopOnboarding.get().targetProfile) - if (generation !== flowGeneration) { return } + if (generation !== flowGeneration) { + return + } patch({ mode: providers.length > 0 ? 'oauth' : 'apikey', providers }) } catch { - if (generation !== flowGeneration) { return } + if (generation !== flowGeneration) { + return + } patch({ mode: 'apikey', providers: [] }) } finally { - if (generation === flowGeneration) { providersRefreshPromise = null } + if (generation === flowGeneration) { + providersRefreshPromise = null + } } })() @@ -706,7 +722,9 @@ export async function startProviderOAuth(provider: OAuthProvider, ctx: Onboardin ) pollTimer = window.setInterval(() => void pollSession(provider, start, ctx, generation), POLL_MS) } catch (error) { - if (generation !== flowGeneration) { return } + if (generation !== flowGeneration) { + return + } setFlow({ status: 'error', provider, message: `Could not start sign-in: ${errMessage(error)}` }) } } @@ -716,7 +734,9 @@ async function pollSession(provider: OAuthProvider, start: DeviceStart, ctx: Onb try { const { error_message, status } = await pollOAuthSession(provider.id, start.session_id, ctx.profile) - if (generation !== flowGeneration) { return } + if (generation !== flowGeneration) { + return + } if (status === 'approved') { clearPoll() @@ -733,7 +753,9 @@ async function pollSession(provider: OAuthProvider, start: DeviceStart, ctx: Onb setFlow({ status: 'error', provider, start, message: error_message || `Sign-in ${status}.` }) } } catch (error) { - if (generation !== flowGeneration) { return } + if (generation !== flowGeneration) { + return + } clearPoll() setFlow({ status: 'error', provider, start, message: `Polling failed: ${errMessage(error)}` }) } @@ -763,7 +785,9 @@ export async function submitOnboardingCode(ctx: OnboardingContext) { try { const resp = await submitOAuthCode(provider.id, start.session_id, code.trim(), ctx.profile) - if (generation !== flowGeneration) { return } + if (generation !== flowGeneration) { + return + } if (resp.ok && resp.status === 'approved') { setFlow({ status: 'success', provider }) @@ -778,7 +802,9 @@ export async function submitOnboardingCode(ctx: OnboardingContext) { setFlow({ status: 'error', provider, start, message: resp.message || 'Token exchange failed.' }) } } catch (error) { - if (generation !== flowGeneration) { return } + if (generation !== flowGeneration) { + return + } setFlow({ status: 'error', provider, start, message: errMessage(error) }) } } @@ -896,7 +922,9 @@ export async function saveOnboardingApiKey( try { await setEnvVar(envKey, trimmed, ctx.profile) - if (generation !== flowGeneration) { return { ok: false } } + if (generation !== flowGeneration) { + return { ok: false } + } // For API-key flows we don't have a definitive provider id (the // user picked which API key they're entering, but the corresponding // backend slug — e.g. OPENROUTER_API_KEY → "openrouter" — is the @@ -950,7 +978,9 @@ export async function saveOnboardingLocalEndpoint(baseUrl: string, apiKey: strin try { const probe = await validateProviderCredential('OPENAI_BASE_URL', url, key) - if (generation !== flowGeneration) { return { ok: false } } + if (generation !== flowGeneration) { + return { ok: false } + } if (!probe.ok && probe.reachable) { return { ok: false, message: probe.message || 'Could not reach that endpoint.' } @@ -975,14 +1005,20 @@ export async function saveOnboardingLocalEndpoint(baseUrl: string, apiKey: strin try { await setMainModelAssignment({ provider: 'custom', model, base_url: url, api_key: key }, ctx.profile) - if (generation !== flowGeneration) { return { ok: false } } + if (generation !== flowGeneration) { + return { ok: false } + } await ctx.requestGateway('reload.env').catch(() => undefined) - if (generation !== flowGeneration) { return { ok: false } } + if (generation !== flowGeneration) { + return { ok: false } + } const runtime = await checkRuntime(ctx) - if (generation !== flowGeneration) { return { ok: false } } + if (generation !== flowGeneration) { + return { ok: false } + } if (!runtime.ready) { const detail = (runtime.reason ?? '').trim() @@ -1017,19 +1053,26 @@ export async function setOnboardingModel(model: string) { setFlow({ ...flow, currentModel: model, saving: true }) try { - await setMainModelAssignment({ - provider: flow.providerSlug, - model - }, flowProfile ?? $desktopOnboarding.get().targetProfile) + await setMainModelAssignment( + { + provider: flow.providerSlug, + model + }, + flowProfile ?? $desktopOnboarding.get().targetProfile + ) - if (generation !== flowGeneration) { return } + if (generation !== flowGeneration) { + return + } const current = $desktopOnboarding.get().flow if (current.status === 'confirming_model') { setFlow({ ...current, currentModel: model, saving: false }) } } catch (error) { - if (generation !== flowGeneration) { return } + if (generation !== flowGeneration) { + return + } notifyError(error, 'Could not change model') const current = $desktopOnboarding.get().flow diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index 1b180c5169..92cc823a9f 100644 --- a/apps/desktop/src/store/projects.ts +++ b/apps/desktop/src/store/projects.ts @@ -536,8 +536,11 @@ export async function fetchProjectSessions(projectId: string): Promise { expect(second).toHaveBeenCalledOnce() stopSecond() }) -}) \ No newline at end of file +}) diff --git a/apps/desktop/src/store/thread-scroll.ts b/apps/desktop/src/store/thread-scroll.ts index 97062834ff..f7d22e87f3 100644 --- a/apps/desktop/src/store/thread-scroll.ts +++ b/apps/desktop/src/store/thread-scroll.ts @@ -216,11 +216,18 @@ function persistPositions(storageKey: string, positions: Record