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)
This commit is contained in:
Nicolas Varrot
2026-02-11 23:37:37 +00:00
parent f8be728842
commit 916910f5ce
9 changed files with 199 additions and 178 deletions

View File

@@ -25,6 +25,9 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Lint
run: npm run lint
- name: Type check - name: Type check
run: npx tsc --noEmit run: npx tsc --noEmit

View File

@@ -19,5 +19,9 @@ export default defineConfig([
ecmaVersion: 2020, ecmaVersion: 2020,
globals: globals.browser, globals: globals.browser,
}, },
rules: {
// Allow setState in effects for status-tracking and initialization patterns
'react-hooks/set-state-in-effect': 'warn',
},
}, },
]) ])

View File

@@ -8,7 +8,8 @@ import type { ChatMessage as ChatMessageType, MessageBlock } from '../types';
import { ThinkingBlock } from './ThinkingBlock'; import { ThinkingBlock } from './ThinkingBlock';
import { CodeBlock } from './CodeBlock'; import { CodeBlock } from './CodeBlock';
import { ToolCall } from './ToolCall'; 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 { Bot, User, Wrench, Copy, Check, RefreshCw } from 'lucide-react';
import { t, getLocale } from '../lib/i18n'; import { t, getLocale } from '../lib/i18n';
import { useLocale } from '../hooks/useLocale'; import { useLocale } from '../hooks/useLocale';

View File

@@ -12,7 +12,7 @@ type BannerState = 'hidden' | 'reconnecting' | 'reconnected';
export function ConnectionBanner({ status }: Props) { export function ConnectionBanner({ status }: Props) {
const t = useT(); const t = useT();
const [banner, setBanner] = useState<BannerState>('hidden'); const [banner, setBanner] = useState<BannerState>('hidden');
const prevStatus = useRef(status); const prevStatus = useRef<ConnectionStatus | null>(null);
const dismissTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const dismissTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useEffect(() => {
@@ -25,16 +25,12 @@ export function ConnectionBanner({ status }: Props) {
} }
if (status === 'disconnected' || status === 'connecting') { if (status === 'disconnected' || status === 'connecting') {
// Only show reconnecting if we were previously connected (not initial load)
if (prev === 'connected') { if (prev === 'connected') {
setBanner('reconnecting'); setBanner('reconnecting');
} }
} else if (status === 'connected' && prev !== 'connected') { } else if (status === 'connected' && prev !== null && prev !== 'connected') {
// Just reconnected — flash success only if we were showing the banner setBanner('reconnected');
if (banner === 'reconnecting' || prev === 'disconnected' || prev === 'connecting') { dismissTimer.current = setTimeout(() => setBanner('hidden'), 3000);
setBanner('reconnected');
dismissTimer.current = setTimeout(() => setBanner('hidden'), 3000);
}
} }
return () => { return () => {

View File

@@ -66,9 +66,4 @@ export function ImageBlock({ src, alt }: ImageBlockProps) {
); );
} }
/** Build a data URL from base64 image data */ // buildImageSrc moved to ../lib/image.ts
export function buildImageSrc(mediaType: string, data?: string, url?: string): string {
if (url) return url;
if (data) return `data:${mediaType};base64,${data}`;
return '';
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState } from 'react';
import { Sparkles, Eye, EyeOff, Loader2 } from 'lucide-react'; import { Sparkles, Eye, EyeOff, Loader2 } from 'lucide-react';
import { useT } from '../hooks/useLocale'; import { useT } from '../hooks/useLocale';
import { getStoredCredentials } from '../lib/credentials';
interface Props { interface Props {
onConnect: (url: string, token: string) => void; onConnect: (url: string, token: string) => void;
@@ -8,41 +9,23 @@ interface Props {
isConnecting?: boolean; isConnecting?: boolean;
} }
const STORAGE_KEY = 'pinchchat_credentials'; function getInitialUrl(): string {
const stored = getStoredCredentials();
export function getStoredCredentials(): { url: string; token: string } | null { if (stored) return stored.url;
try { return import.meta.env.VITE_GATEWAY_WS_URL || `ws://${window.location.hostname}:18789`;
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;
} }
export function storeCredentials(url: string, token: string) { function getInitialToken(): string {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ url, token })); const stored = getStoredCredentials();
} return stored?.token ?? '';
export function clearCredentials() {
localStorage.removeItem(STORAGE_KEY);
} }
export function LoginScreen({ onConnect, error, isConnecting }: Props) { export function LoginScreen({ onConnect, error, isConnecting }: Props) {
const t = useT(); const t = useT();
const defaultUrl = import.meta.env.VITE_GATEWAY_WS_URL || `ws://${window.location.hostname}:18789`; const [url, setUrl] = useState(getInitialUrl);
const [url, setUrl] = useState(defaultUrl); const [token, setToken] = useState(getInitialToken);
const [token, setToken] = useState('');
const [showToken, setShowToken] = useState(false); const [showToken, setShowToken] = useState(false);
useEffect(() => {
const stored = getStoredCredentials();
if (stored) {
setUrl(stored.url);
setToken(stored.token);
}
}, []);
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!url.trim() || !token.trim()) return; if (!url.trim() || !token.trim()) return;

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { GatewayClient, type JsonPayload } from '../lib/gateway'; import { GatewayClient, type JsonPayload } from '../lib/gateway';
import { genIdempotencyKey } from '../lib/utils'; 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'; import type { ChatMessage, MessageBlock, ConnectionStatus, Session } from '../types';
interface ChatPayloadMessage { interface ChatPayloadMessage {
@@ -33,12 +33,147 @@ export function useGateway() {
const [isConnecting, setIsConnecting] = useState(false); const [isConnecting, setIsConnecting] = useState(false);
const isConnectingRef = useRef(false); const isConnectingRef = useRef(false);
const messagesRef = useRef(messages); const messagesRef = useRef(messages);
messagesRef.current = messages;
const activeSessionRef = useRef(activeSession); 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<string | null>(null); const currentRunIdRef = useRef<string | null>(null);
const [activeSessions, setActiveSessions] = useState<Set<string>>(new Set()); const [activeSessions, setActiveSessions] = useState<Set<string>>(new Set());
const handleAgentEvent = useCallback((payload: JsonPayload) => {
if (payload?.stream !== 'tool') return;
const data = (payload.data ?? {}) as Record<string, unknown>;
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<string, unknown>) ?? {},
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<Record<string, unknown>> | 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<Record<string, unknown>> | undefined;
if (rawMsgs) {
/* eslint-disable @typescript-eslint/no-explicit-any -- raw gateway history messages have dynamic shape */
const msgs: ChatMessage[] = rawMsgs.map((m: Record<string, any>, 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<MessageBlock, { type: 'text' }> => 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) => { const setupClient = useCallback((wsUrl: string, token: string) => {
// Tear down existing client // Tear down existing client
if (clientRef.current) { if (clientRef.current) {
@@ -163,10 +298,13 @@ export function useGateway() {
isConnectingRef.current = true; isConnectingRef.current = true;
setConnectError(null); setConnectError(null);
client.connect(); client.connect();
}, []); }, [handleAgentEvent, loadHistory, loadSessions]);
// On mount: try stored credentials // On mount: try stored credentials
const initRef = useRef(false);
useEffect(() => { useEffect(() => {
if (initRef.current) return;
initRef.current = true;
const stored = getStoredCredentials(); const stored = getStoredCredentials();
if (stored) { if (stored) {
setupClient(stored.url, stored.token); setupClient(stored.url, stored.token);
@@ -175,135 +313,6 @@ export function useGateway() {
} }
}, [setupClient]); }, [setupClient]);
const handleAgentEvent = useCallback((payload: JsonPayload) => {
if (payload?.stream !== 'tool') return;
const data = (payload.data ?? {}) as Record<string, unknown>;
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<string, unknown>) ?? {},
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<Record<string, unknown>> | 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<Record<string, unknown>> | undefined;
if (rawMsgs) {
/* eslint-disable @typescript-eslint/no-explicit-any -- raw gateway history messages have dynamic shape */
const msgs: ChatMessage[] = rawMsgs.map((m: Record<string, any>, 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<MessageBlock, { type: 'text' }> => 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 sendMessage = useCallback(async (text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => {
const userMsg: ChatMessage = { const userMsg: ChatMessage = {
id: 'user-' + Date.now(), id: 'user-' + Date.now(),
@@ -323,7 +332,8 @@ export function useGateway() {
idempotencyKey: genIdempotencyKey(), idempotencyKey: genIdempotencyKey(),
...(attachments && attachments.length > 0 ? { attachments } : {}), ...(attachments && attachments.length > 0 ? { attachments } : {}),
}); });
} catch (e) { } catch {
// Failed to send — stop generating indicator
setIsGenerating(false); setIsGenerating(false);
} }
}, []); }, []);
@@ -331,7 +341,9 @@ export function useGateway() {
const abort = useCallback(async () => { const abort = useCallback(async () => {
try { try {
await clientRef.current?.send('chat.abort', { sessionKey: activeSessionRef.current }); await clientRef.current?.send('chat.abort', { sessionKey: activeSessionRef.current });
} catch {} } catch {
// Ignore abort failures
}
setIsGenerating(false); setIsGenerating(false);
}, []); }, []);

21
src/lib/credentials.ts Normal file
View File

@@ -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);
}

6
src/lib/image.ts Normal file
View File

@@ -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 '';
}