From f1772cf8b3f818b911f65038bdd3c12b589f5362 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 26 Sep 2026 12:37:41 -0700 Subject: [PATCH 1/5] fix(desktop): keep built-in browser logins across app restarts --- .../browser-agent/session-cookies.test.ts | 105 ++++++++++++++++++ .../src/main/browser-agent/session-cookies.ts | 60 ++++++++++ .../src/main/browser-agent/session.test.ts | 20 ++++ .../desktop/src/main/browser-agent/session.ts | 11 +- apps/desktop/src/test/electron-mock.ts | 2 +- 5 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/main/browser-agent/session-cookies.test.ts create mode 100644 apps/desktop/src/main/browser-agent/session-cookies.ts diff --git a/apps/desktop/src/main/browser-agent/session-cookies.test.ts b/apps/desktop/src/main/browser-agent/session-cookies.test.ts new file mode 100644 index 00000000000..4a4a3267446 --- /dev/null +++ b/apps/desktop/src/main/browser-agent/session-cookies.test.ts @@ -0,0 +1,105 @@ +/** + * @vitest-environment node + */ +import type { OnHeadersReceivedListenerDetails, Session } from 'electron' +import { describe, expect, it, vi } from 'vitest' +import { + keepSessionCookiesAcrossRestarts, + SESSION_COOKIE_LIFETIME_SECONDS, + withSessionCookieLifetime, + withSessionCookieMaxAge, +} from '@/main/browser-agent/session-cookies' + +vi.mock('electron', () => import('@/test/electron-mock')) + +const NOW = 1_800_000_000 +const MAX_AGE = `Max-Age=${SESSION_COOKIE_LIFETIME_SECONDS}` + +describe('withSessionCookieLifetime', () => { + it('gives an expiry-less cookie the bounded lifetime', () => { + expect(withSessionCookieLifetime({ url: 'https://example.com/', name: 'a' }, NOW)).toEqual({ + url: 'https://example.com/', + name: 'a', + expirationDate: NOW + SESSION_COOKIE_LIFETIME_SECONDS, + }) + }) + + it('leaves a cookie that already expires untouched', () => { + const cookie = { url: 'https://example.com/', name: 'a', expirationDate: NOW + 60 } + expect(withSessionCookieLifetime(cookie, NOW)).toBe(cookie) + }) +}) + +describe('withSessionCookieMaxAge', () => { + it('adds Max-Age to a session cookie', () => { + expect(withSessionCookieMaxAge('sid=abc; Path=/; Secure; HttpOnly')).toBe( + `sid=abc; Path=/; Secure; HttpOnly; ${MAX_AGE}` + ) + expect(withSessionCookieMaxAge('sid=abc')).toBe(`sid=abc; ${MAX_AGE}`) + }) + + it('leaves cookies with an expiry or a deletion untouched', () => { + for (const value of [ + 'sid=abc; Expires=Wed, 21 Oct 2030 07:28:00 GMT', + 'sid=abc; max-age=60', + 'sid=; Path=/; Max-Age=0', + 'sid=abc;EXPIRES=Thu, 01 Jan 1970 00:00:00 GMT', + ]) { + expect(withSessionCookieMaxAge(value)).toBe(value) + } + }) + + it('reads attributes only, never the cookie value', () => { + expect(withSessionCookieMaxAge('max-age=1')).toBe(`max-age=1; ${MAX_AGE}`) + }) +}) + +describe('keepSessionCookiesAcrossRestarts', () => { + type Listener = ( + details: Pick, + callback: (response: { responseHeaders?: Record }) => void + ) => void + + function install(): Listener { + let listener: Listener | undefined + const ses = { + webRequest: { + onHeadersReceived: vi.fn((handler: Listener) => { + listener = handler + }), + }, + } + keepSessionCookiesAcrossRestarts(ses as unknown as Session) + if (!listener) throw new Error('listener not installed') + return listener + } + + it('rewrites every Set-Cookie value and keeps the other headers', () => { + const callback = vi.fn() + install()( + { + responseHeaders: { + 'content-type': ['text/html'], + 'Set-Cookie': ['sid=abc; HttpOnly', 'keep=1; Max-Age=60'], + }, + }, + callback + ) + + expect(callback).toHaveBeenCalledWith({ + responseHeaders: { + 'content-type': ['text/html'], + 'Set-Cookie': [`sid=abc; HttpOnly; ${MAX_AGE}`, 'keep=1; Max-Age=60'], + }, + }) + }) + + it('leaves responses without cookies untouched', () => { + const callback = vi.fn() + install()({ responseHeaders: { 'content-type': ['text/html'] } }, callback) + install()({}, callback) + + expect(callback).toHaveBeenNthCalledWith(1, {}) + expect(callback).toHaveBeenNthCalledWith(2, {}) + }) +}) diff --git a/apps/desktop/src/main/browser-agent/session-cookies.ts b/apps/desktop/src/main/browser-agent/session-cookies.ts new file mode 100644 index 00000000000..b46853d941d --- /dev/null +++ b/apps/desktop/src/main/browser-agent/session-cookies.ts @@ -0,0 +1,60 @@ +import type { CookiesSetDetails, Session } from 'electron' + +/** + * Session cookies in the browser partition get this lifetime instead of dying + * with the process. + * + * Electron hard-codes Chromium's `persist_session_cookies` and + * `restore_old_session_cookies` to false, so a `persist:` partition still drops + * every expiry-less cookie on quit — which signs the user out of any site that + * keeps its login in one, whether it was imported from Chrome or set here. + * Chrome keeps them across restarts under "continue where you left off"; this + * matches that, bounded. The site re-sending the cookie renews the window, so + * only a login left untouched for this long lapses. + */ +export const SESSION_COOKIE_LIFETIME_SECONDS = 30 * 24 * 60 * 60 + +/** Gives an expiry-less cookie the bounded lifetime; cookies that already expire are unchanged. */ +export function withSessionCookieLifetime( + cookie: CookiesSetDetails, + nowSeconds: number +): CookiesSetDetails { + if (cookie.expirationDate !== undefined) return cookie + return { ...cookie, expirationDate: nowSeconds + SESSION_COOKIE_LIFETIME_SECONDS } +} + +/** + * Adds `Max-Age` to a `Set-Cookie` value that has neither `Expires` nor + * `Max-Age`. Deletions carry one of the two, so they pass through untouched. + */ +export function withSessionCookieMaxAge(setCookie: string): string { + const attributes = setCookie.split(';').slice(1) + const expires = attributes.some((attribute) => { + const name = attribute.split('=', 1)[0].trim().toLowerCase() + return name === 'expires' || name === 'max-age' + }) + return expires ? setCookie : `${setCookie}; Max-Age=${SESSION_COOKIE_LIFETIME_SECONDS}` +} + +/** + * Makes session cookies a site sets over HTTP survive an app restart. + * + * The rewrite happens on the response headers, before Chromium stores the + * cookie, so it is created persistent in the same order as every other cookie + * write — a later deletion from the site still wins. Rewriting stored cookies + * after the fact would race exactly that: login redirects set and clear + * short-lived cookies milliseconds apart. Cookies set from page script + * (`document.cookie`) are not covered; they cannot be `HttpOnly`, so session + * logins rarely live in them. + */ +export function keepSessionCookiesAcrossRestarts(ses: Session): void { + ses.webRequest.onHeadersReceived((details, callback) => { + const headers = details.responseHeaders + const key = headers && Object.keys(headers).find((name) => name.toLowerCase() === 'set-cookie') + if (!headers || !key) { + callback({}) + return + } + callback({ responseHeaders: { ...headers, [key]: headers[key].map(withSessionCookieMaxAge) } }) + }) +} diff --git a/apps/desktop/src/main/browser-agent/session.test.ts b/apps/desktop/src/main/browser-agent/session.test.ts index 07f34f26609..96c2ddb379a 100644 --- a/apps/desktop/src/main/browser-agent/session.test.ts +++ b/apps/desktop/src/main/browser-agent/session.test.ts @@ -2277,6 +2277,26 @@ describe('importAgentCookies', () => { sameSite: 'lax' as const, }) + it('gives session cookies a bounded lifetime so they survive a restart', async () => { + vi.useFakeTimers({ now: new Date('2026-09-26T00:00:00Z') }) + try { + const set = vi.fn(async () => {}) + const session = withCookieJar(set) + const expiring = { ...cookie('kept'), expirationDate: 1_900_000_000 } + + await session.importAgentCookies([cookie('session'), expiring]) + + const nowSeconds = Date.parse('2026-09-26T00:00:00Z') / 1000 + expect(set).toHaveBeenNthCalledWith(1, { + ...cookie('session'), + expirationDate: nowSeconds + 30 * 24 * 60 * 60, + }) + expect(set).toHaveBeenNthCalledWith(2, expiring) + } finally { + vi.useRealTimers() + } + }) + it('counts a rejected cookie without losing the rest', async () => { // Chromium refuses cookies whose attributes are inconsistent. That // rejection must cost one cookie, not the whole import. diff --git a/apps/desktop/src/main/browser-agent/session.ts b/apps/desktop/src/main/browser-agent/session.ts index d1643b12dbe..1b7de802598 100644 --- a/apps/desktop/src/main/browser-agent/session.ts +++ b/apps/desktop/src/main/browser-agent/session.ts @@ -72,6 +72,10 @@ import { registerAgentWebContents, } from '@/main/browser-agent/registry' import { handleBrowserRequest } from '@/main/browser-agent/request-policy' +import { + keepSessionCookiesAcrossRestarts, + withSessionCookieLifetime, +} from '@/main/browser-agent/session-cookies' import { clearHostVerdictCache } from '@/main/browser-agent/url-guard' import type { BrowserSessionSnapshot } from '@/main/desktop-chat-session-store' import { suggestedFilename, uniqueDownloadPath } from '@/main/downloads' @@ -1327,17 +1331,19 @@ export async function listAgentCookieSignals(): Promise { * here and is counted rather than being quietly relaxed. * * Failures are per-cookie: one rejected cookie must not cost the user the - * rest. Nothing about a cookie is logged. + * rest. Nothing about a cookie is logged. Session cookies get the bounded + * lifetime from {@link withSessionCookieLifetime} so they survive a restart. */ export async function importAgentCookies( cookies: CookiesSetDetails[] ): Promise<{ imported: number; failed: number }> { const jar = electronSession.fromPartition(AGENT_PARTITION).cookies + const nowSeconds = Date.now() / 1000 let imported = 0 let failed = 0 for (const cookie of cookies) { try { - await jar.set(cookie) + await jar.set(withSessionCookieLifetime(cookie, nowSeconds)) imported += 1 } catch { failed += 1 @@ -1594,6 +1600,7 @@ const browserPermissions: BrowserPermissionHandlers = { function configureAgentPartition(ses: Session): void { if (configuredPartitions.has(ses)) return configuredPartitions.add(ses) + keepSessionCookiesAcrossRestarts(ses) ses.setPermissionRequestHandler(browserPermissions.request) ses.setPermissionCheckHandler(browserPermissions.check) ses.webRequest.onBeforeRequest((details, callback) => { diff --git a/apps/desktop/src/test/electron-mock.ts b/apps/desktop/src/test/electron-mock.ts index d929be7057f..822eab9e3ad 100644 --- a/apps/desktop/src/test/electron-mock.ts +++ b/apps/desktop/src/test/electron-mock.ts @@ -241,7 +241,7 @@ function createWebContentsMock() { setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), setUserAgent: vi.fn(), - webRequest: { onBeforeRequest: vi.fn() }, + webRequest: { onBeforeRequest: vi.fn(), onHeadersReceived: vi.fn() }, on: vi.fn(), }, } From 36965cbb8b1734489b1c7f8e5c768ac322f1d36f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 26 Sep 2026 16:28:23 -0700 Subject: [PATCH 2/5] test(desktop): cover session-cookie persistence with a relaunch e2e --- apps/desktop/e2e/session-cookies.spec.ts | 178 ++++++++++++++++++ .../browser-agent/session-cookies.test.ts | 56 +----- .../src/main/browser-agent/session.test.ts | 20 -- 3 files changed, 179 insertions(+), 75 deletions(-) create mode 100644 apps/desktop/e2e/session-cookies.spec.ts diff --git a/apps/desktop/e2e/session-cookies.spec.ts b/apps/desktop/e2e/session-cookies.spec.ts new file mode 100644 index 00000000000..7c85b99e63f --- /dev/null +++ b/apps/desktop/e2e/session-cookies.spec.ts @@ -0,0 +1,178 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { createServer, type Server } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { type ElectronApplication, _electron as electron, expect, test } from '@playwright/test' +import type { SimDesktopApi } from '@sim/desktop-bridge' + +const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url)) +const SCOPE = 'session-cookies-fixture' + +/** + * Electron drops every expiry-less cookie on quit, so a site that keeps its + * login in a session cookie signed the user out of the built-in browser on + * every restart. This drives a real quit and relaunch against one profile. + */ +test.describe('built-in browser session cookies', () => { + let server: Server + let origin: string + let site: string + let userData: string + let app: ElectronApplication | undefined + let serial = 0 + let lastCookieHeader: string | undefined + const calls = new Map< + string, + { chatId: string; toolName: string; args: Record } + >() + + test.beforeAll(async () => { + server = createServer(async (request, response) => { + const path = new URL(request.url ?? '/', 'http://localhost').pathname + if (path === '/api/auth/get-session') { + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.end( + JSON.stringify({ user: { id: 'fixture-user' }, session: { id: 'fixture-session' } }) + ) + return + } + if (path === '/api/desktop/tool/authorize') { + let body = '' + for await (const chunk of request) body += chunk.toString() + const call = calls.get(JSON.parse(body).toolCallId) + response.writeHead(call ? 200 : 403, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify(call ?? {})) + return + } + if (path.startsWith('/api/')) { + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.end('{}') + return + } + if (path === '/sign-in') { + // A login redirect that sets a short-lived cookie the next hop deletes. + response.writeHead(302, { + 'Set-Cookie': 'oauth_state=pending; HttpOnly; Path=/', + Location: '/signed-in', + }) + response.end() + return + } + if (path === '/signed-in') { + response.writeHead(200, { + 'Content-Type': 'text/html', + 'Set-Cookie': [ + 'oauth_state=; Path=/; Max-Age=0', + 'login=fixture; HttpOnly; SameSite=Lax; Path=/', + 'remember=1; Path=/; Max-Age=3600', + ], + }) + response.end('Signed in') + return + } + if (path === '/account') { + lastCookieHeader = request.headers.cookie ?? '' + response.writeHead(200, { 'Content-Type': 'text/html' }) + response.end('Account') + return + } + if (request.headers.host?.startsWith('localhost')) { + // Favicon and other stray site requests must not set the app session cookie on the site. + response.writeHead(404) + response.end() + return + } + response.writeHead(200, { + 'Content-Type': 'text/html', + 'Set-Cookie': 'better-auth.session_token=fixture; HttpOnly; SameSite=Lax; Path=/', + }) + response.end('Sim fixture

Session cookie fixture

') + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('Missing fixture address') + origin = `http://127.0.0.1:${address.port}` + /** Pages outside the app origin browse in the built-in browser's own partition. */ + site = `http://localhost:${address.port}` + }) + + test.beforeEach(() => { + userData = mkdtempSync(join(tmpdir(), 'sim-session-cookies-e2e-')) + }) + + test.afterEach(async () => { + await app?.close() + app = undefined + rmSync(userData, { recursive: true, force: true }) + calls.clear() + }) + + test.afterAll(async () => { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ) + }) + + async function launch(): Promise { + const launched = await electron.launch({ + args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'], + cwd: DESKTOP_DIR, + env: { ...process.env, SIM_DESKTOP_ORIGIN: origin, SIM_DESKTOP_USER_DATA: userData }, + }) + const host = await launched.firstWindow() + await expect(host.getByRole('heading')).toHaveText('Session cookie fixture') + await host.evaluate(async (scope) => { + const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop + await api.browserAgent.activateScope(scope) + const updateBounds = () => + api.browserAgent.setPanelBounds( + { x: 0, y: 80, width: innerWidth, height: innerHeight - 80 }, + null, + scope + ) + updateBounds() + setInterval(updateBounds, 200) + }, SCOPE) + return launched + } + + async function navigate(target: ElectronApplication, url: string) { + const id = `fixture-${++serial}` + calls.set(id, { chatId: SCOPE, toolName: 'browser_open_url', args: { url } }) + const host = await target.firstWindow() + const result = await host.evaluate( + async ({ id, url, scope }) => { + const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop + return api.browserAgent.executeTool(id, 'browser_open_url', { url }, scope) + }, + { id, url, scope: SCOPE } + ) + expect(result.ok, result.error).toBe(true) + } + + async function cookiesSentToSite(target: ElectronApplication): Promise { + lastCookieHeader = undefined + await navigate(target, `${site}/account`) + await expect.poll(() => lastCookieHeader).not.toBeUndefined() + return (lastCookieHeader ?? '') + .split(';') + .map((pair) => pair.trim()) + .filter(Boolean) + .sort() + } + + test('keeps a session-cookie login across a restart without reviving deleted cookies', async () => { + app = await launch() + await navigate(app, `${site}/sign-in`) + expect(await cookiesSentToSite(app)).toEqual(['login=fixture', 'remember=1']) + + await app.evaluate(({ session }) => + session.fromPartition('persist:sim-browser-agent').cookies.flushStore() + ) + await app.close() + + app = await launch() + expect(await cookiesSentToSite(app)).toEqual(['login=fixture', 'remember=1']) + }) +}) diff --git a/apps/desktop/src/main/browser-agent/session-cookies.test.ts b/apps/desktop/src/main/browser-agent/session-cookies.test.ts index 4a4a3267446..c20381eb983 100644 --- a/apps/desktop/src/main/browser-agent/session-cookies.test.ts +++ b/apps/desktop/src/main/browser-agent/session-cookies.test.ts @@ -1,17 +1,13 @@ /** * @vitest-environment node */ -import type { OnHeadersReceivedListenerDetails, Session } from 'electron' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { - keepSessionCookiesAcrossRestarts, SESSION_COOKIE_LIFETIME_SECONDS, withSessionCookieLifetime, withSessionCookieMaxAge, } from '@/main/browser-agent/session-cookies' -vi.mock('electron', () => import('@/test/electron-mock')) - const NOW = 1_800_000_000 const MAX_AGE = `Max-Age=${SESSION_COOKIE_LIFETIME_SECONDS}` @@ -53,53 +49,3 @@ describe('withSessionCookieMaxAge', () => { expect(withSessionCookieMaxAge('max-age=1')).toBe(`max-age=1; ${MAX_AGE}`) }) }) - -describe('keepSessionCookiesAcrossRestarts', () => { - type Listener = ( - details: Pick, - callback: (response: { responseHeaders?: Record }) => void - ) => void - - function install(): Listener { - let listener: Listener | undefined - const ses = { - webRequest: { - onHeadersReceived: vi.fn((handler: Listener) => { - listener = handler - }), - }, - } - keepSessionCookiesAcrossRestarts(ses as unknown as Session) - if (!listener) throw new Error('listener not installed') - return listener - } - - it('rewrites every Set-Cookie value and keeps the other headers', () => { - const callback = vi.fn() - install()( - { - responseHeaders: { - 'content-type': ['text/html'], - 'Set-Cookie': ['sid=abc; HttpOnly', 'keep=1; Max-Age=60'], - }, - }, - callback - ) - - expect(callback).toHaveBeenCalledWith({ - responseHeaders: { - 'content-type': ['text/html'], - 'Set-Cookie': [`sid=abc; HttpOnly; ${MAX_AGE}`, 'keep=1; Max-Age=60'], - }, - }) - }) - - it('leaves responses without cookies untouched', () => { - const callback = vi.fn() - install()({ responseHeaders: { 'content-type': ['text/html'] } }, callback) - install()({}, callback) - - expect(callback).toHaveBeenNthCalledWith(1, {}) - expect(callback).toHaveBeenNthCalledWith(2, {}) - }) -}) diff --git a/apps/desktop/src/main/browser-agent/session.test.ts b/apps/desktop/src/main/browser-agent/session.test.ts index 96c2ddb379a..07f34f26609 100644 --- a/apps/desktop/src/main/browser-agent/session.test.ts +++ b/apps/desktop/src/main/browser-agent/session.test.ts @@ -2277,26 +2277,6 @@ describe('importAgentCookies', () => { sameSite: 'lax' as const, }) - it('gives session cookies a bounded lifetime so they survive a restart', async () => { - vi.useFakeTimers({ now: new Date('2026-09-26T00:00:00Z') }) - try { - const set = vi.fn(async () => {}) - const session = withCookieJar(set) - const expiring = { ...cookie('kept'), expirationDate: 1_900_000_000 } - - await session.importAgentCookies([cookie('session'), expiring]) - - const nowSeconds = Date.parse('2026-09-26T00:00:00Z') / 1000 - expect(set).toHaveBeenNthCalledWith(1, { - ...cookie('session'), - expirationDate: nowSeconds + 30 * 24 * 60 * 60, - }) - expect(set).toHaveBeenNthCalledWith(2, expiring) - } finally { - vi.useRealTimers() - } - }) - it('counts a rejected cookie without losing the rest', async () => { // Chromium refuses cookies whose attributes are inconsistent. That // rejection must cost one cookie, not the whole import. From 47ffe0018c17d11de3adcec38ffcb468ab1ab2a2 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 28 Sep 2026 10:41:06 -0700 Subject: [PATCH 3/5] improvement(tab-strip): reveal the active tab in a fixed 200ms --- .../src/components/tab-strip/tab-strip.tsx | 43 ++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/packages/emcn/src/components/tab-strip/tab-strip.tsx b/packages/emcn/src/components/tab-strip/tab-strip.tsx index baef5cd3202..c7ed37f27a3 100644 --- a/packages/emcn/src/components/tab-strip/tab-strip.tsx +++ b/packages/emcn/src/components/tab-strip/tab-strip.tsx @@ -22,7 +22,13 @@ import { scrollFadeAttributes, scrollFadeXClass, } from '@sim/emcn' -import { AnimatePresence, motion, useReducedMotion } from 'framer-motion' +import { + AnimatePresence, + type AnimationPlaybackControls, + animate, + motion, + useReducedMotion, +} from 'framer-motion' import { Plus, X } from '../../icons' import { cn } from '../../lib/cn' import { Button } from '../button/button' @@ -32,6 +38,12 @@ import { TabStripAction } from './tab-strip-action' const DRAG_EDGE_ZONE = 40 const DRAG_SCROLL_SPEED = 8 const TAB_TRANSITION = { duration: 0.1, ease: [0.2, 0, 0, 1] as const } +/** + * Fixed duration for bringing the active tab into view. Native smooth scrolling + * takes longer the further it travels, so a crowded strip crawled across dozens + * of tabs. + */ +const REVEAL_SCROLL_TRANSITION = { duration: 0.2, ease: TAB_TRANSITION.ease } /** * Width, not flex-basis: `flex-1` compiles to `flex: 1 1 0%`, and Tailwind emits @@ -508,6 +520,7 @@ export function TabStrip({ const dropTargetIndexRef = useRef(null) const autoScrollRafRef = useRef(null) const autoScrollDirectionRef = useRef(0) + const revealScrollRef = useRef(null) const focusedTabRef = useRef<{ id: string element: HTMLButtonElement @@ -537,6 +550,11 @@ export function TabStrip({ setCanScrollRight(node.scrollLeft < maxScrollLeft - 1) }, []) + const stopRevealScroll = useCallback(() => { + revealScrollRef.current?.stop() + revealScrollRef.current = null + }, []) + const stopAutoScroll = useCallback(() => { if (autoScrollRafRef.current !== null) cancelAnimationFrame(autoScrollRafRef.current) autoScrollRafRef.current = null @@ -579,9 +597,20 @@ export function TabStrip({ // gradient at a scroll extreme, so no margin is needed to clear one. const nextLeft = Math.max(0, Math.min(maxScrollLeft, target)) if (Math.abs(nextLeft - node.scrollLeft) < 1) return - const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false - node.scrollTo({ left: nextLeft, behavior: reduceMotion ? 'auto' : 'smooth' }) - }, [activeRegularId, regularTabOrder]) + stopRevealScroll() + if (reduceMotion) { + node.scrollLeft = nextLeft + return + } + revealScrollRef.current = animate(node.scrollLeft, nextLeft, { + ...REVEAL_SCROLL_TRANSITION, + onUpdate: (left) => { + node.scrollLeft = left + }, + }) + }, [activeRegularId, regularTabOrder, reduceMotion, stopRevealScroll]) + + useEffect(() => stopRevealScroll, [stopRevealScroll]) useLayoutEffect(() => { revealActiveTab() @@ -621,13 +650,14 @@ export function TabStrip({ event.deltaY ) if (next === null) return + stopRevealScroll() node.scrollLeft = next updateOverflow() event.preventDefault() } strip.addEventListener('wheel', handleWheel, { passive: false }) return () => strip.removeEventListener('wheel', handleWheel) - }, [updateOverflow]) + }, [stopRevealScroll, updateOverflow]) const handleDragStart = useCallback( (event: ReactDragEvent, id: string) => { @@ -677,6 +707,7 @@ export function TabStrip({ stopAutoScroll() if (direction === 0) return autoScrollDirectionRef.current = direction + stopRevealScroll() const tick = () => { const before = node.scrollLeft node.scrollLeft += direction * DRAG_SCROLL_SPEED @@ -690,7 +721,7 @@ export function TabStrip({ } autoScrollRafRef.current = requestAnimationFrame(tick) }, - [stopAutoScroll, tabs, updateOverflow] + [stopAutoScroll, stopRevealScroll, tabs, updateOverflow] ) const handleDragOver = useCallback( From 1a6a7085b077d2078b0184991dacfdba153d18f1 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 28 Sep 2026 11:06:46 -0700 Subject: [PATCH 4/5] fix(tab-strip): stop an in-flight reveal on every retarget and on pointer scrolling --- packages/emcn/src/components/tab-strip/tab-strip.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/emcn/src/components/tab-strip/tab-strip.tsx b/packages/emcn/src/components/tab-strip/tab-strip.tsx index c7ed37f27a3..14ef4adf5e5 100644 --- a/packages/emcn/src/components/tab-strip/tab-strip.tsx +++ b/packages/emcn/src/components/tab-strip/tab-strip.tsx @@ -574,6 +574,9 @@ export function TabStrip({ const revealActiveTab = useCallback(() => { const node = scrollNodeRef.current if (!node || !activeRegularId) return + // Stopped before any early return, so a reveal still in flight toward a + // previous tab cannot scroll an already-visible active tab away. + stopRevealScroll() const element = Array.from(node.querySelectorAll('[data-tab-strip-item]')).find( (candidate) => candidate.dataset.tabStripItem === activeRegularId ) @@ -597,7 +600,6 @@ export function TabStrip({ // gradient at a scroll extreme, so no margin is needed to clear one. const nextLeft = Math.max(0, Math.min(maxScrollLeft, target)) if (Math.abs(nextLeft - node.scrollLeft) < 1) return - stopRevealScroll() if (reduceMotion) { node.scrollLeft = nextLeft return @@ -656,7 +658,12 @@ export function TabStrip({ event.preventDefault() } strip.addEventListener('wheel', handleWheel, { passive: false }) - return () => strip.removeEventListener('wheel', handleWheel) + // Touch, pen, and scrollbar drags scroll natively without a wheel event. + strip.addEventListener('pointerdown', stopRevealScroll) + return () => { + strip.removeEventListener('wheel', handleWheel) + strip.removeEventListener('pointerdown', stopRevealScroll) + } }, [stopRevealScroll, updateOverflow]) const handleDragStart = useCallback( From ee6c10eae1ac384beac93b28afbf4a73fe015cb8 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 28 Sep 2026 11:22:17 -0700 Subject: [PATCH 5/5] fix(tab-strip): yield the reveal only to scrolling it did not cause --- .../src/components/tab-strip/tab-strip.tsx | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/emcn/src/components/tab-strip/tab-strip.tsx b/packages/emcn/src/components/tab-strip/tab-strip.tsx index 14ef4adf5e5..0eb6a5ea160 100644 --- a/packages/emcn/src/components/tab-strip/tab-strip.tsx +++ b/packages/emcn/src/components/tab-strip/tab-strip.tsx @@ -521,6 +521,7 @@ export function TabStrip({ const autoScrollRafRef = useRef(null) const autoScrollDirectionRef = useRef(0) const revealScrollRef = useRef(null) + const revealScrollLeftRef = useRef(0) const focusedTabRef = useRef<{ id: string element: HTMLButtonElement @@ -607,6 +608,7 @@ export function TabStrip({ revealScrollRef.current = animate(node.scrollLeft, nextLeft, { ...REVEAL_SCROLL_TRANSITION, onUpdate: (left) => { + revealScrollLeftRef.current = left node.scrollLeft = left }, }) @@ -626,17 +628,25 @@ export function TabStrip({ revealActiveTab() } updateLayout() - node.addEventListener('scroll', updateOverflow, { passive: true }) + const handleScroll = () => { + updateOverflow() + // A position the reveal did not set means touch, the scrollbar, or the + // keyboard took over scrolling, so the reveal yields to it. + if (revealScrollRef.current && Math.abs(node.scrollLeft - revealScrollLeftRef.current) > 1) { + stopRevealScroll() + } + } + node.addEventListener('scroll', handleScroll, { passive: true }) if (typeof ResizeObserver === 'undefined') { - return () => node.removeEventListener('scroll', updateOverflow) + return () => node.removeEventListener('scroll', handleScroll) } const observer = new ResizeObserver(updateLayout) observer.observe(node) return () => { observer.disconnect() - node.removeEventListener('scroll', updateOverflow) + node.removeEventListener('scroll', handleScroll) } - }, [regularTabs.length, revealActiveTab, updateOverflow]) + }, [regularTabs.length, revealActiveTab, stopRevealScroll, updateOverflow]) useEffect(() => { const strip = stripRef.current @@ -658,12 +668,7 @@ export function TabStrip({ event.preventDefault() } strip.addEventListener('wheel', handleWheel, { passive: false }) - // Touch, pen, and scrollbar drags scroll natively without a wheel event. - strip.addEventListener('pointerdown', stopRevealScroll) - return () => { - strip.removeEventListener('wheel', handleWheel) - strip.removeEventListener('pointerdown', stopRevealScroll) - } + return () => strip.removeEventListener('wheel', handleWheel) }, [stopRevealScroll, updateOverflow]) const handleDragStart = useCallback(