diff --git a/src/renderer/src/components/CodexRestartChip.tsx b/src/renderer/src/components/CodexRestartChip.tsx index 7530ee3ff..fa644f79d 100644 --- a/src/renderer/src/components/CodexRestartChip.tsx +++ b/src/renderer/src/components/CodexRestartChip.tsx @@ -1,6 +1,5 @@ -import { useEffect, useId, useRef, useState } from 'react' +import { useEffect, useId, useMemo, useRef, useState } from 'react' import { RefreshCw } from 'lucide-react' -import { useShallow } from 'zustand/react/shallow' import { Button } from '@/components/ui/button' import { useAppStore } from '../store' import { translate } from '@/i18n/i18n' @@ -73,20 +72,21 @@ export default function CodexRestartChip({ isVisible?: boolean worktreeId: string }): React.JSX.Element | null { - const { staleWorktreePtyIds, restartNotice } = useAppStore( - useShallow((s) => { - const stalePtyIds = collectStalePtyIdsForTabs({ - tabs: s.tabsByWorktree[worktreeId] ?? EMPTY_TABS, - ptyIdsByTabId: s.ptyIdsByTabId, - codexRestartNoticeByPtyId: s.codexRestartNoticeByPtyId - }) - const firstStalePtyId = stalePtyIds[0] - return { - staleWorktreePtyIds: stalePtyIds, - restartNotice: firstStalePtyId ? s.codexRestartNoticeByPtyId[firstStalePtyId] : undefined - } - }) + const tabs = useAppStore((s) => s.tabsByWorktree[worktreeId] ?? EMPTY_TABS) + const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) + const codexRestartNoticeByPtyId = useAppStore((s) => s.codexRestartNoticeByPtyId) + const staleWorktreePtyIds = useMemo( + () => + collectStalePtyIdsForTabs({ + tabs, + ptyIdsByTabId, + codexRestartNoticeByPtyId + }), + [codexRestartNoticeByPtyId, ptyIdsByTabId, tabs] ) + const restartNotice = staleWorktreePtyIds[0] + ? codexRestartNoticeByPtyId[staleWorktreePtyIds[0]] + : undefined const queueCodexPaneRestarts = useAppStore((s) => s.queueCodexPaneRestarts) const clearCodexRestartNotice = useAppStore((s) => s.clearCodexRestartNotice) @@ -96,10 +96,11 @@ export default function CodexRestartChip({ ) const currentCollapseState = getCodexRestartOverlayCollapseState(collapseState, noticeKey) - // Why: a new account switch must reopen loud mode even if the prior notice was collapsed. - if (currentCollapseState !== collapseState) { - setCollapseState(currentCollapseState) - } + useEffect(() => { + // Why: a new account switch must reopen loud mode without updating state + // during render, which can trip React's external-store snapshot guard. + setCollapseState((state) => getCodexRestartOverlayCollapseState(state, noticeKey)) + }, [noticeKey]) if (staleWorktreePtyIds.length === 0 || !restartNotice) { return null diff --git a/src/renderer/src/components/codex-restart-chip.test.ts b/src/renderer/src/components/codex-restart-chip.test.ts index 456e6db8b..af542372b 100644 --- a/src/renderer/src/components/codex-restart-chip.test.ts +++ b/src/renderer/src/components/codex-restart-chip.test.ts @@ -1,10 +1,33 @@ -import { describe, expect, it, vi } from 'vitest' -import { +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '../store' +import CodexRestartChip, { collectStalePtyIdsForTabs, collectStaleWorktreePtyIds, dismissStaleWorktreePtyIds } from './CodexRestartChip' +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + useAppStore.setState(useAppStore.getInitialState(), true) +}) + describe('CodexRestartChip helpers', () => { it('collects all stale PTY ids for tabs in a worktree', () => { expect( @@ -68,4 +91,38 @@ describe('CodexRestartChip helpers', () => { expect(clearCodexRestartNotice).toHaveBeenNthCalledWith(2, 'pty-3') expect(clearCodexRestartNotice).toHaveBeenCalledTimes(2) }) + + it('renders a stale Codex restart notice without an external-store update loop', async () => { + useAppStore.setState({ + tabsByWorktree: { + 'worktree-1': [ + { + id: 'tab-1', + worktreeId: 'worktree-1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: null + } + ] + }, + ptyIdsByTabId: { + 'tab-1': ['pty-1'] + }, + codexRestartNoticeByPtyId: { + 'pty-1': { + previousAccountLabel: 'old@example.com', + nextAccountLabel: 'new@example.com' + } + } + }) + + await act(async () => { + root.render(React.createElement(CodexRestartChip, { worktreeId: 'worktree-1' })) + }) + + expect(container.textContent).toContain('Codex is still signed in as old@example.com') + }) }) diff --git a/tests/e2e/terminal-cursor-raster-probe.ts b/tests/e2e/terminal-cursor-raster-probe.ts index a8db8435b..9c5267c76 100644 --- a/tests/e2e/terminal-cursor-raster-probe.ts +++ b/tests/e2e/terminal-cursor-raster-probe.ts @@ -25,7 +25,16 @@ export type ViewportSize = { } function isCursorProbePixel(red: number, green: number, blue: number, alpha: number): boolean { - return alpha > 240 && Math.abs(red - 35) <= 2 && green >= 253 && Math.abs(blue - 69) <= 2 + if (alpha <= 240) { + return false + } + + const isRawProbeColor = Math.abs(red - 35) <= 2 && green >= 253 && Math.abs(blue - 69) <= 2 + // Why: WebGL can composite the forced cursor color over the terminal + // background before Playwright captures pixels, especially on macOS. + const isCompositedProbeColor = + Math.abs(red - 121) <= 3 && green >= 249 && Math.abs(blue - 100) <= 3 + return isRawProbeColor || isCompositedProbeColor } export function analyzeRasterCursorCells(