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:
@@ -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<string | null>(null);
|
||||
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) => {
|
||||
// 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<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 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);
|
||||
}, []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user