diff --git a/electron/main.ts b/electron/main.ts index 4a142e8..aeb11c4 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -169,6 +169,7 @@ async function waitForServer(port: number, timeout = 30000): Promise { } try { await new Promise((resolve, reject) => { + // eslint-disable-next-line @typescript-eslint/no-require-imports const req = require('http').get(`http://127.0.0.1:${port}/api/health`, (res: { statusCode?: number }) => { if (res.statusCode === 200) resolve(); else reject(new Error(`Status ${res.statusCode}`)); diff --git a/electron/preload.ts b/electron/preload.ts index e5ab885..f9b469b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,3 +1,4 @@ +// eslint-disable-next-line @typescript-eslint/no-require-imports const { contextBridge } = require('electron'); contextBridge.exposeInMainWorld('electronAPI', { diff --git a/scripts/after-pack.js b/scripts/after-pack.js index 46110f6..1fc721b 100644 --- a/scripts/after-pack.js +++ b/scripts/after-pack.js @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ /** * electron-builder afterPack hook. * diff --git a/src/__tests__/smoke-test.ts b/src/__tests__/smoke-test.ts index d5716a2..5329c3c 100644 --- a/src/__tests__/smoke-test.ts +++ b/src/__tests__/smoke-test.ts @@ -78,9 +78,9 @@ async function runSmokeTests() { await page.screenshot({ path: `/Users/op7418/Documents/code/opus-4.6-test/src/__tests__/screenshots/${screenshotName}.png`, fullPage: true }); results.push({ name: route.name, status: 'PASS', details, consoleErrors, loadTimeMs: elapsed }); - } catch (err: any) { + } catch (err: unknown) { const elapsed = Date.now() - start; - results.push({ name: route.name, status: 'FAIL', details: err.message, consoleErrors, loadTimeMs: elapsed }); + results.push({ name: route.name, status: 'FAIL', details: err instanceof Error ? err.message : String(err), consoleErrors, loadTimeMs: elapsed }); } await page.close(); } diff --git a/src/__tests__/unit/db-shutdown.test.ts b/src/__tests__/unit/db-shutdown.test.ts index 2bd140a..abb31f6 100644 --- a/src/__tests__/unit/db-shutdown.test.ts +++ b/src/__tests__/unit/db-shutdown.test.ts @@ -21,7 +21,7 @@ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codepilot-db-test-')); process.env.CLAUDE_GUI_DATA_DIR = tmpDir; // Use require to avoid top-level await issues with CJS output -/* eslint-disable @typescript-eslint/no-var-requires */ +/* eslint-disable @typescript-eslint/no-require-imports */ const { getDb, closeDb, createSession, getSession } = require('../../lib/db') as typeof import('../../lib/db'); describe('closeDb', () => { diff --git a/src/components/ai-elements/code-block.tsx b/src/components/ai-elements/code-block.tsx index 164f5e3..44dac44 100644 --- a/src/components/ai-elements/code-block.tsx +++ b/src/components/ai-elements/code-block.tsx @@ -389,27 +389,32 @@ export const CodeBlockContent = ({ const rawTokens = useMemo(() => createRawTokens(code), [code]); // Try to get cached result synchronously, otherwise use raw tokens - const [tokenized, setTokenized] = useState( - () => highlightCode(code, language) ?? rawTokens + const syncTokenized = useMemo( + () => highlightCode(code, language) ?? rawTokens, + [code, language, rawTokens] ); + // Track async highlighting results keyed by code+language to avoid stale state + const [asyncResult, setAsyncResult] = useState<{ key: string; tokens: TokenizedCode } | null>(null); + const resultKey = `${code}:${language}`; + useEffect(() => { let cancelled = false; - // Reset to raw tokens when code changes (shows current code, not stale tokens) - setTokenized(highlightCode(code, language) ?? rawTokens); - // Subscribe to async highlighting result highlightCode(code, language, (result) => { if (!cancelled) { - setTokenized(result); + setAsyncResult({ key: `${code}:${language}`, tokens: result }); } }); return () => { cancelled = true; }; - }, [code, language, rawTokens]); + }, [code, language]); + + // Only use async result if it matches the current code+language + const tokenized = (asyncResult && asyncResult.key === resultKey) ? asyncResult.tokens : syncTokenized; return (
diff --git a/src/components/ai-elements/shimmer.tsx b/src/components/ai-elements/shimmer.tsx index 5962cf8..a7ab89c 100644 --- a/src/components/ai-elements/shimmer.tsx +++ b/src/components/ai-elements/shimmer.tsx @@ -1,32 +1,16 @@ "use client"; -import type { MotionProps } from "motion/react"; -import type { CSSProperties, ElementType, JSX } from "react"; +import type { CSSProperties } from "react"; import { cn } from "@/lib/utils"; import { motion } from "motion/react"; import { memo, useMemo } from "react"; -type MotionHTMLProps = MotionProps & Record; - -// Cache motion components at module level to avoid creating during render -const motionComponentCache = new Map< - keyof JSX.IntrinsicElements, - React.ComponentType ->(); - -const getMotionComponent = (element: keyof JSX.IntrinsicElements) => { - let component = motionComponentCache.get(element); - if (!component) { - component = motion.create(element); - motionComponentCache.set(element, component); - } - return component; -}; +// Pre-create the motion.p component at module level to avoid creating during render +const MotionP = motion.create("p"); export interface TextShimmerProps { children: string; - as?: ElementType; className?: string; duration?: number; spread?: number; @@ -34,22 +18,17 @@ export interface TextShimmerProps { const ShimmerComponent = ({ children, - as: Component = "p", className, duration = 2, spread = 2, }: TextShimmerProps) => { - const MotionComponent = getMotionComponent( - Component as keyof JSX.IntrinsicElements - ); - const dynamicSpread = useMemo( () => (children?.length ?? 0) * spread, [children, spread] ); return ( - {children} - + ); }; diff --git a/src/components/ai-elements/tool-actions-group.tsx b/src/components/ai-elements/tool-actions-group.tsx index f1cb43b..24a8471 100644 --- a/src/components/ai-elements/tool-actions-group.tsx +++ b/src/components/ai-elements/tool-actions-group.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useRef } from 'react'; +import { useState } from 'react'; import { motion, AnimatePresence } from 'motion/react'; import { HugeiconsIcon } from "@hugeicons/react"; import type { IconSvgElement } from "@hugeicons/react"; @@ -198,14 +198,11 @@ export function ToolActionsGroup({ }: ToolActionsGroupProps) { const hasRunningTool = tools.some((t) => t.result === undefined); - const [userToggled, setUserToggled] = useState(false); - const [expanded, setExpanded] = useState(hasRunningTool); + // Track whether user has manually toggled and their chosen state + const [userExpandedState, setUserExpandedState] = useState(null); - useEffect(() => { - if (!userToggled) { - setExpanded(hasRunningTool || isStreaming); - } - }, [hasRunningTool, isStreaming, userToggled]); + // Derived: if user has toggled, use their choice; otherwise auto-expand based on streaming state + const expanded = userExpandedState !== null ? userExpandedState : (hasRunningTool || isStreaming); if (tools.length === 0) return null; @@ -214,8 +211,7 @@ export function ToolActionsGroup({ const runningDesc = getRunningDescription(tools); const handleToggle = () => { - setUserToggled(true); - setExpanded((prev) => !prev); + setUserExpandedState((prev) => prev !== null ? !prev : !expanded); }; // Build summary text parts diff --git a/src/components/chat/StreamingMessage.tsx b/src/components/chat/StreamingMessage.tsx index 4dba8aa..2062482 100644 --- a/src/components/chat/StreamingMessage.tsx +++ b/src/components/chat/StreamingMessage.tsx @@ -47,9 +47,10 @@ interface StreamingMessageProps { function ElapsedTimer() { const [elapsed, setElapsed] = useState(0); - const startRef = useRef(Date.now()); + const startRef = useRef(0); useEffect(() => { + startRef.current = Date.now(); const interval = setInterval(() => { setElapsed(Math.floor((Date.now() - startRef.current) / 1000)); }, 1000); diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 60a6a15..5e3df62 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,7 +1,7 @@ "use client"; import { useTheme } from "next-themes"; -import { useEffect, useState } from "react"; +import { useCallback, useSyncExternalStore } from "react"; import { HugeiconsIcon } from "@hugeicons/react"; import { Moon02Icon, Sun02Icon } from "@hugeicons/core-free-icons"; import { Button } from "@/components/ui/button"; @@ -13,11 +13,8 @@ import { export function Header() { const { theme, setTheme } = useTheme(); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); + const emptySubscribe = useCallback(() => () => {}, []); + const mounted = useSyncExternalStore(emptySubscribe, () => true, () => false); return (
diff --git a/src/components/layout/NavRail.tsx b/src/components/layout/NavRail.tsx index aa5cc04..990834b 100644 --- a/src/components/layout/NavRail.tsx +++ b/src/components/layout/NavRail.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useTheme } from "next-themes"; -import { useEffect, useState } from "react"; +import { useCallback, useSyncExternalStore } from "react"; import { HugeiconsIcon } from "@hugeicons/react"; import { Message02Icon, @@ -38,13 +38,10 @@ export function NavRail({ chatListOpen, onToggleChatList, hasUpdate, skipPermiss const pathname = usePathname(); const router = useRouter(); const { theme, setTheme } = useTheme(); - const [mounted, setMounted] = useState(false); + const emptySubscribe = useCallback(() => () => {}, []); + const mounted = useSyncExternalStore(emptySubscribe, () => true, () => false); const isChatRoute = pathname === "/chat" || pathname.startsWith("/chat/"); - useEffect(() => { - setMounted(true); - }, []); - return (