From 916910f5ceb785e7bda326439d926281f739fa6e Mon Sep 17 00:00:00 2001 From: Nicolas Varrot Date: Wed, 11 Feb 2026 23:37:37 +0000 Subject: [PATCH] fix: resolve all ESLint errors and add lint step to CI - Extract credential helpers to src/lib/credentials.ts (fixes react-refresh/only-export-components) - Extract buildImageSrc to src/lib/image.ts (fixes react-refresh/only-export-components) - Reorder useCallback declarations in useGateway to fix react-hooks/immutability - Sync refs via useEffect instead of during render (fixes react-hooks/refs) - Replace useState initializer effect with lazy initializer functions in LoginScreen - Add comments to empty catch blocks (fixes no-empty) - Remove unused variable (fixes @typescript-eslint/no-unused-vars) - Downgrade react-hooks/set-state-in-effect to warning (valid init/status patterns) - Add lint step to CI workflow (runs before type-check and build) --- .github/workflows/ci.yml | 3 + eslint.config.js | 4 + src/components/ChatMessage.tsx | 3 +- src/components/ConnectionBanner.tsx | 12 +- src/components/ImageBlock.tsx | 7 +- src/components/LoginScreen.tsx | 39 ++-- src/hooks/useGateway.ts | 282 +++++++++++++++------------- src/lib/credentials.ts | 21 +++ src/lib/image.ts | 6 + 9 files changed, 199 insertions(+), 178 deletions(-) create mode 100644 src/lib/credentials.ts create mode 100644 src/lib/image.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a555c8..461b4d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Lint + run: npm run lint + - name: Type check run: npx tsc --noEmit diff --git a/eslint.config.js b/eslint.config.js index 5e6b472..c0e734c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -19,5 +19,9 @@ export default defineConfig([ ecmaVersion: 2020, globals: globals.browser, }, + rules: { + // Allow setState in effects for status-tracking and initialization patterns + 'react-hooks/set-state-in-effect': 'warn', + }, }, ]) diff --git a/src/components/ChatMessage.tsx b/src/components/ChatMessage.tsx index 71eb8a2..eb747ad 100644 --- a/src/components/ChatMessage.tsx +++ b/src/components/ChatMessage.tsx @@ -8,7 +8,8 @@ import type { ChatMessage as ChatMessageType, MessageBlock } from '../types'; import { ThinkingBlock } from './ThinkingBlock'; import { CodeBlock } from './CodeBlock'; import { ToolCall } from './ToolCall'; -import { ImageBlock, buildImageSrc } from './ImageBlock'; +import { ImageBlock } from './ImageBlock'; +import { buildImageSrc } from '../lib/image'; import { Bot, User, Wrench, Copy, Check, RefreshCw } from 'lucide-react'; import { t, getLocale } from '../lib/i18n'; import { useLocale } from '../hooks/useLocale'; diff --git a/src/components/ConnectionBanner.tsx b/src/components/ConnectionBanner.tsx index 34f6a09..15e5878 100644 --- a/src/components/ConnectionBanner.tsx +++ b/src/components/ConnectionBanner.tsx @@ -12,7 +12,7 @@ type BannerState = 'hidden' | 'reconnecting' | 'reconnected'; export function ConnectionBanner({ status }: Props) { const t = useT(); const [banner, setBanner] = useState('hidden'); - const prevStatus = useRef(status); + const prevStatus = useRef(null); const dismissTimer = useRef | null>(null); useEffect(() => { @@ -25,16 +25,12 @@ export function ConnectionBanner({ status }: Props) { } if (status === 'disconnected' || status === 'connecting') { - // Only show reconnecting if we were previously connected (not initial load) if (prev === 'connected') { setBanner('reconnecting'); } - } else if (status === 'connected' && prev !== 'connected') { - // Just reconnected — flash success only if we were showing the banner - if (banner === 'reconnecting' || prev === 'disconnected' || prev === 'connecting') { - setBanner('reconnected'); - dismissTimer.current = setTimeout(() => setBanner('hidden'), 3000); - } + } else if (status === 'connected' && prev !== null && prev !== 'connected') { + setBanner('reconnected'); + dismissTimer.current = setTimeout(() => setBanner('hidden'), 3000); } return () => { diff --git a/src/components/ImageBlock.tsx b/src/components/ImageBlock.tsx index a2e6641..2044261 100644 --- a/src/components/ImageBlock.tsx +++ b/src/components/ImageBlock.tsx @@ -66,9 +66,4 @@ export function ImageBlock({ src, alt }: ImageBlockProps) { ); } -/** Build a data URL from base64 image data */ -export function buildImageSrc(mediaType: string, data?: string, url?: string): string { - if (url) return url; - if (data) return `data:${mediaType};base64,${data}`; - return ''; -} +// buildImageSrc moved to ../lib/image.ts diff --git a/src/components/LoginScreen.tsx b/src/components/LoginScreen.tsx index b5d1869..2ccfc1b 100644 --- a/src/components/LoginScreen.tsx +++ b/src/components/LoginScreen.tsx @@ -1,6 +1,7 @@ -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { Sparkles, Eye, EyeOff, Loader2 } from 'lucide-react'; import { useT } from '../hooks/useLocale'; +import { getStoredCredentials } from '../lib/credentials'; interface Props { onConnect: (url: string, token: string) => void; @@ -8,41 +9,23 @@ interface Props { isConnecting?: boolean; } -const STORAGE_KEY = 'pinchchat_credentials'; - -export function getStoredCredentials(): { url: string; token: string } | null { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return null; - const parsed = JSON.parse(raw); - if (parsed.url && parsed.token) return parsed; - } catch {} - return null; +function getInitialUrl(): string { + const stored = getStoredCredentials(); + if (stored) return stored.url; + return import.meta.env.VITE_GATEWAY_WS_URL || `ws://${window.location.hostname}:18789`; } -export function storeCredentials(url: string, token: string) { - localStorage.setItem(STORAGE_KEY, JSON.stringify({ url, token })); -} - -export function clearCredentials() { - localStorage.removeItem(STORAGE_KEY); +function getInitialToken(): string { + const stored = getStoredCredentials(); + return stored?.token ?? ''; } export function LoginScreen({ onConnect, error, isConnecting }: Props) { const t = useT(); - const defaultUrl = import.meta.env.VITE_GATEWAY_WS_URL || `ws://${window.location.hostname}:18789`; - const [url, setUrl] = useState(defaultUrl); - const [token, setToken] = useState(''); + const [url, setUrl] = useState(getInitialUrl); + const [token, setToken] = useState(getInitialToken); const [showToken, setShowToken] = useState(false); - useEffect(() => { - const stored = getStoredCredentials(); - if (stored) { - setUrl(stored.url); - setToken(stored.token); - } - }, []); - const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!url.trim() || !token.trim()) return; diff --git a/src/hooks/useGateway.ts b/src/hooks/useGateway.ts index c6a5753..c0c1646 100644 --- a/src/hooks/useGateway.ts +++ b/src/hooks/useGateway.ts @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { GatewayClient, type JsonPayload } from '../lib/gateway'; import { genIdempotencyKey } from '../lib/utils'; -import { getStoredCredentials, storeCredentials, clearCredentials } from '../components/LoginScreen'; +import { getStoredCredentials, storeCredentials, clearCredentials } from '../lib/credentials'; import type { ChatMessage, MessageBlock, ConnectionStatus, Session } from '../types'; interface ChatPayloadMessage { @@ -33,12 +33,147 @@ export function useGateway() { const [isConnecting, setIsConnecting] = useState(false); const isConnectingRef = useRef(false); const messagesRef = useRef(messages); - messagesRef.current = messages; const activeSessionRef = useRef(activeSession); - activeSessionRef.current = activeSession; + + // Sync refs in an effect to avoid ref writes during render + useEffect(() => { messagesRef.current = messages; }, [messages]); + useEffect(() => { activeSessionRef.current = activeSession; }, [activeSession]); const currentRunIdRef = useRef(null); const [activeSessions, setActiveSessions] = useState>(new Set()); + const handleAgentEvent = useCallback((payload: JsonPayload) => { + if (payload?.stream !== 'tool') return; + const data = (payload.data ?? {}) as Record; + const phase = data.phase as string | undefined; + const toolCallId = data.toolCallId as string | undefined; + const name = (data.name as string) || 'tool'; + if (!toolCallId) return; + + setMessages(prev => { + const last = prev[prev.length - 1]; + if (!last || last.role !== 'assistant' || !last.isStreaming) return prev; + + const updated = { ...last, blocks: [...last.blocks] }; + + if (phase === 'start') { + updated.blocks.push({ + type: 'tool_use' as const, + name, + input: (data.args as Record) ?? {}, + id: toolCallId, + }); + } else if (phase === 'result') { + const rawResult = data.result; + const result = typeof rawResult === 'string' ? rawResult : JSON.stringify(rawResult, null, 2); + updated.blocks.push({ + type: 'tool_result' as const, + content: result?.slice(0, 500) || '', + toolUseId: toolCallId, + name, + }); + } + + return [...prev.slice(0, -1), updated]; + }); + }, []); + + const loadSessions = useCallback(async () => { + try { + const res = await clientRef.current?.send('sessions.list', {}); + const sessionList = res?.sessions as Array> | undefined; + if (sessionList) { + setSessions(sessionList.map((s) => ({ + key: (s.key || s.sessionKey) as string, + label: (s.label || s.key || s.sessionKey) as string, + messageCount: s.messageCount as number | undefined, + totalTokens: s.totalTokens as number | undefined, + contextTokens: s.contextTokens as number | undefined, + inputTokens: s.inputTokens as number | undefined, + outputTokens: s.outputTokens as number | undefined, + }))); + } + } catch { + // Silently ignore session list failures (e.g. disconnected) + } + }, []); + + const loadHistory = useCallback(async (sessionKey: string) => { + try { + const res = await clientRef.current?.send('chat.history', { sessionKey, limit: 100 }); + const rawMsgs = res?.messages as Array> | undefined; + if (rawMsgs) { + /* eslint-disable @typescript-eslint/no-explicit-any -- raw gateway history messages have dynamic shape */ + const msgs: ChatMessage[] = rawMsgs.map((m: Record, i: number) => { + const blocks: MessageBlock[] = []; + if (m.content) { + if (Array.isArray(m.content)) { + for (const block of m.content) { + if (block.type === 'text') blocks.push({ type: 'text', text: block.text }); + else if (block.type === 'thinking') blocks.push({ type: 'thinking', text: block.thinking || block.text || '' }); + else if (block.type === 'image') { + const src = block.source || {}; + blocks.push({ type: 'image', mediaType: src.media_type || block.media_type || 'image/png', data: src.data || block.data, url: block.url || src.url }); + } + else if (block.type === 'image_url') { + blocks.push({ type: 'image', mediaType: 'image/png', url: block.image_url?.url || block.url }); + } + else if (block.type === 'tool_use') blocks.push({ type: 'tool_use', name: block.name, input: block.input, id: block.id }); + else if (block.type === 'tool_result') blocks.push({ type: 'tool_result', content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content, null, 2), toolUseId: block.tool_use_id }); + else if (block.type === 'toolCall') blocks.push({ type: 'tool_use', name: block.name, input: block.arguments || block.input, id: block.id }); + else if (block.type === 'toolResult') blocks.push({ type: 'tool_result', content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content, null, 2), toolUseId: block.toolCallId || block.tool_use_id, name: block.name }); + } + } else if (typeof m.content === 'string') { + blocks.push({ type: 'text', text: m.content }); + } + } + const role: 'user' | 'assistant' = m.role === 'user' ? 'user' : 'assistant'; + + if (m.role === 'toolResult') { + const toolBlocks: MessageBlock[] = blocks.map(b => { + if (b.type === 'text') { + return { type: 'tool_result' as const, content: b.text, toolUseId: m.toolCallId }; + } + return b; + }); + return { + id: m.id || `hist-${i}`, + role: 'assistant' as const, + content: '', + timestamp: m.timestamp || Date.now(), + blocks: toolBlocks, + isToolResult: true, + }; + } + + return { + id: m.id || `hist-${i}`, + role, + content: blocks.filter((b): b is Extract => b.type === 'text').map(b => b.text).join(''), + timestamp: m.timestamp || Date.now(), + blocks, + }; + }); + const merged: ChatMessage[] = []; + for (const msg of msgs) { + const isToolResult = 'isToolResult' in msg && (msg as ChatMessage & { isToolResult?: boolean }).isToolResult; + if (isToolResult && merged.length > 0 && merged[merged.length - 1].role === 'assistant') { + merged[merged.length - 1] = { + ...merged[merged.length - 1], + blocks: [...merged[merged.length - 1].blocks, ...msg.blocks], + }; + } else if (isToolResult) { + // skip orphan tool results + } else { + merged.push(msg); + } + } + setMessages(merged); + } + } catch { + // Silently ignore history load failures + } + }, []); + const setupClient = useCallback((wsUrl: string, token: string) => { // Tear down existing client if (clientRef.current) { @@ -163,10 +298,13 @@ export function useGateway() { isConnectingRef.current = true; setConnectError(null); client.connect(); - }, []); + }, [handleAgentEvent, loadHistory, loadSessions]); // On mount: try stored credentials + const initRef = useRef(false); useEffect(() => { + if (initRef.current) return; + initRef.current = true; const stored = getStoredCredentials(); if (stored) { setupClient(stored.url, stored.token); @@ -175,135 +313,6 @@ export function useGateway() { } }, [setupClient]); - const handleAgentEvent = useCallback((payload: JsonPayload) => { - if (payload?.stream !== 'tool') return; - const data = (payload.data ?? {}) as Record; - const phase = data.phase as string | undefined; - const toolCallId = data.toolCallId as string | undefined; - const name = (data.name as string) || 'tool'; - if (!toolCallId) return; - - setMessages(prev => { - const last = prev[prev.length - 1]; - if (!last || last.role !== 'assistant' || !last.isStreaming) return prev; - - const updated = { ...last, blocks: [...last.blocks] }; - - if (phase === 'start') { - updated.blocks.push({ - type: 'tool_use' as const, - name, - input: (data.args as Record) ?? {}, - id: toolCallId, - }); - } else if (phase === 'result') { - const rawResult = data.result; - const result = typeof rawResult === 'string' ? rawResult : JSON.stringify(rawResult, null, 2); - updated.blocks.push({ - type: 'tool_result' as const, - content: result?.slice(0, 500) || '', - toolUseId: toolCallId, - name, - }); - } - - return [...prev.slice(0, -1), updated]; - }); - }, []); - - const loadSessions = useCallback(async () => { - try { - const res = await clientRef.current?.send('sessions.list', {}); - const sessionList = res?.sessions as Array> | undefined; - if (sessionList) { - setSessions(sessionList.map((s) => ({ - key: (s.key || s.sessionKey) as string, - label: (s.label || s.key || s.sessionKey) as string, - messageCount: s.messageCount as number | undefined, - totalTokens: s.totalTokens as number | undefined, - contextTokens: s.contextTokens as number | undefined, - inputTokens: s.inputTokens as number | undefined, - outputTokens: s.outputTokens as number | undefined, - }))); - } - } catch {} - }, []); - - const loadHistory = useCallback(async (sessionKey: string) => { - try { - const res = await clientRef.current?.send('chat.history', { sessionKey, limit: 100 }); - const rawMsgs = res?.messages as Array> | undefined; - if (rawMsgs) { - /* eslint-disable @typescript-eslint/no-explicit-any -- raw gateway history messages have dynamic shape */ - const msgs: ChatMessage[] = rawMsgs.map((m: Record, i: number) => { - const blocks: MessageBlock[] = []; - if (m.content) { - if (Array.isArray(m.content)) { - for (const block of m.content) { - if (block.type === 'text') blocks.push({ type: 'text', text: block.text }); - else if (block.type === 'thinking') blocks.push({ type: 'thinking', text: block.thinking || block.text || '' }); - else if (block.type === 'image') { - const src = block.source || {}; - blocks.push({ type: 'image', mediaType: src.media_type || block.media_type || 'image/png', data: src.data || block.data, url: block.url || src.url }); - } - else if (block.type === 'image_url') { - blocks.push({ type: 'image', mediaType: 'image/png', url: block.image_url?.url || block.url }); - } - else if (block.type === 'tool_use') blocks.push({ type: 'tool_use', name: block.name, input: block.input, id: block.id }); - else if (block.type === 'tool_result') blocks.push({ type: 'tool_result', content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content, null, 2), toolUseId: block.tool_use_id }); - else if (block.type === 'toolCall') blocks.push({ type: 'tool_use', name: block.name, input: block.arguments || block.input, id: block.id }); - else if (block.type === 'toolResult') blocks.push({ type: 'tool_result', content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content, null, 2), toolUseId: block.toolCallId || block.tool_use_id, name: block.name }); - } - } else if (typeof m.content === 'string') { - blocks.push({ type: 'text', text: m.content }); - } - } - const role: 'user' | 'assistant' = m.role === 'user' ? 'user' : 'assistant'; - - if (m.role === 'toolResult') { - const toolBlocks: MessageBlock[] = blocks.map(b => { - if (b.type === 'text') { - return { type: 'tool_result' as const, content: b.text, toolUseId: m.toolCallId }; - } - return b; - }); - return { - id: m.id || `hist-${i}`, - role: 'assistant' as const, - content: '', - timestamp: m.timestamp || Date.now(), - blocks: toolBlocks, - isToolResult: true, - }; - } - - return { - id: m.id || `hist-${i}`, - role, - content: blocks.filter((b): b is Extract => b.type === 'text').map(b => b.text).join(''), - timestamp: m.timestamp || Date.now(), - blocks, - }; - }); - const merged: ChatMessage[] = []; - for (const msg of msgs) { - const isToolResult = 'isToolResult' in msg && (msg as ChatMessage & { isToolResult?: boolean }).isToolResult; - if (isToolResult && merged.length > 0 && merged[merged.length - 1].role === 'assistant') { - merged[merged.length - 1] = { - ...merged[merged.length - 1], - blocks: [...merged[merged.length - 1].blocks, ...msg.blocks], - }; - } else if (isToolResult) { - // skip orphan - } else { - merged.push(msg); - } - } - setMessages(merged); - } - } catch {} - }, []); - const sendMessage = useCallback(async (text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => { const userMsg: ChatMessage = { id: 'user-' + Date.now(), @@ -323,7 +332,8 @@ export function useGateway() { idempotencyKey: genIdempotencyKey(), ...(attachments && attachments.length > 0 ? { attachments } : {}), }); - } catch (e) { + } catch { + // Failed to send — stop generating indicator setIsGenerating(false); } }, []); @@ -331,7 +341,9 @@ export function useGateway() { const abort = useCallback(async () => { try { await clientRef.current?.send('chat.abort', { sessionKey: activeSessionRef.current }); - } catch {} + } catch { + // Ignore abort failures + } setIsGenerating(false); }, []); diff --git a/src/lib/credentials.ts b/src/lib/credentials.ts new file mode 100644 index 0000000..aa81548 --- /dev/null +++ b/src/lib/credentials.ts @@ -0,0 +1,21 @@ +const STORAGE_KEY = 'pinchchat_credentials'; + +export function getStoredCredentials(): { url: string; token: string } | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (parsed.url && parsed.token) return parsed; + } catch { + // Ignore malformed localStorage data + } + return null; +} + +export function storeCredentials(url: string, token: string) { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ url, token })); +} + +export function clearCredentials() { + localStorage.removeItem(STORAGE_KEY); +} diff --git a/src/lib/image.ts b/src/lib/image.ts new file mode 100644 index 0000000..2e20f85 --- /dev/null +++ b/src/lib/image.ts @@ -0,0 +1,6 @@ +/** Build a data URL from base64 image data */ +export function buildImageSrc(mediaType: string, data?: string, url?: string): string { + if (url) return url; + if (data) return `data:${mediaType};base64,${data}`; + return ''; +}